Fixing timezone issue with caldav.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
using System.Reflection;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Integration.Processors;
|
||||
using Wino.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public class CalDavEventTimeMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseCalendarData_UtcEvent_AssignsUtcTimeZone()
|
||||
{
|
||||
const string ics = """
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Wino Mail//Tests//EN
|
||||
CALSCALE:GREGORIAN
|
||||
BEGIN:VEVENT
|
||||
UID:utc-event
|
||||
DTSTAMP:20260201T000000Z
|
||||
DTSTART:20260219T010000Z
|
||||
DTEND:20260219T020000Z
|
||||
SUMMARY:UTC Event
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
""";
|
||||
|
||||
var events = ParseEvents(ics);
|
||||
|
||||
events.Should().ContainSingle();
|
||||
events[0].StartTimeZone.Should().Be(TimeZoneInfo.Utc.Id);
|
||||
events[0].EndTimeZone.Should().Be(TimeZoneInfo.Utc.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManageCalendarEventAsync_PersistsWallClockTimeForSourceTimeZone()
|
||||
{
|
||||
var calendar = new AccountCalendar
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Calendar"
|
||||
};
|
||||
|
||||
var remoteEvent = new CalDavCalendarEvent
|
||||
{
|
||||
RemoteEventId = "event-1",
|
||||
Title = "Wall Clock Event",
|
||||
Start = new DateTimeOffset(2026, 2, 19, 1, 0, 0, TimeSpan.FromHours(1)),
|
||||
End = new DateTimeOffset(2026, 2, 19, 2, 0, 0, TimeSpan.FromHours(1)),
|
||||
StartTimeZone = "Europe/Berlin",
|
||||
EndTimeZone = "Europe/Berlin"
|
||||
};
|
||||
|
||||
CalendarItem? capturedItem = null;
|
||||
var calendarService = new Mock<ICalendarService>();
|
||||
calendarService
|
||||
.Setup(x => x.GetCalendarItemAsync(calendar.Id, remoteEvent.RemoteEventId))
|
||||
.ReturnsAsync((CalendarItem?)null);
|
||||
calendarService
|
||||
.Setup(x => x.CreateNewCalendarItemAsync(It.IsAny<CalendarItem>(), It.IsAny<List<CalendarEventAttendee>>()))
|
||||
.Callback<CalendarItem, List<CalendarEventAttendee>>((item, _) => capturedItem = item)
|
||||
.Returns(Task.CompletedTask);
|
||||
calendarService
|
||||
.Setup(x => x.SaveRemindersAsync(It.IsAny<Guid>(), It.IsAny<List<Reminder>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new ImapChangeProcessor(
|
||||
Mock.Of<IDatabaseService>(),
|
||||
Mock.Of<IFolderService>(),
|
||||
Mock.Of<IMailService>(),
|
||||
Mock.Of<IAccountService>(),
|
||||
calendarService.Object,
|
||||
Mock.Of<IMimeFileService>(),
|
||||
Mock.Of<ICalendarIcsFileService>());
|
||||
|
||||
await sut.ManageCalendarEventAsync(remoteEvent, calendar, organizerAccount: null);
|
||||
|
||||
capturedItem.Should().NotBeNull();
|
||||
var savedItem = capturedItem!;
|
||||
savedItem.StartDate.Should().Be(new DateTime(2026, 2, 19, 1, 0, 0));
|
||||
savedItem.DurationInSeconds.Should().Be(3600);
|
||||
savedItem.StartTimeZone.Should().Be("Europe/Berlin");
|
||||
savedItem.EndTimeZone.Should().Be("Europe/Berlin");
|
||||
}
|
||||
|
||||
private static List<CalDavCalendarEvent> ParseEvents(string icsContent)
|
||||
{
|
||||
var parseMethod = typeof(CalDavClient).GetMethod(
|
||||
"ParseCalendarData",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
parseMethod.Should().NotBeNull();
|
||||
|
||||
var result = parseMethod!.Invoke(
|
||||
null,
|
||||
[
|
||||
icsContent,
|
||||
"https://calendar.example.com/event.ics",
|
||||
"\"etag\"",
|
||||
new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero)
|
||||
]);
|
||||
|
||||
return result.Should().BeOfType<List<CalDavCalendarEvent>>().Subject;
|
||||
}
|
||||
}
|
||||
@@ -40,18 +40,29 @@ public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor
|
||||
var savingItemId = existingItem?.Id ?? Guid.NewGuid();
|
||||
var savingItem = existingItem ?? new CalendarItem { Id = savingItemId };
|
||||
|
||||
var start = calendarEvent.Start.UtcDateTime;
|
||||
var end = calendarEvent.End.UtcDateTime;
|
||||
var startTimeZone = NormalizeTimeZoneId(calendarEvent.StartTimeZone, calendarEvent.Start);
|
||||
var endTimeZone = NormalizeTimeZoneId(calendarEvent.EndTimeZone, calendarEvent.End);
|
||||
if (string.IsNullOrWhiteSpace(endTimeZone))
|
||||
endTimeZone = startTimeZone;
|
||||
|
||||
if (end <= start)
|
||||
end = start.AddHours(1);
|
||||
var start = ConvertToEventWallClock(calendarEvent.Start, startTimeZone);
|
||||
var end = ConvertToEventWallClock(calendarEvent.End, endTimeZone);
|
||||
|
||||
var durationInSeconds = (calendarEvent.End - calendarEvent.Start).TotalSeconds;
|
||||
if (durationInSeconds <= 0)
|
||||
{
|
||||
if (end <= start)
|
||||
end = start.AddHours(1);
|
||||
|
||||
durationInSeconds = (end - start).TotalSeconds;
|
||||
}
|
||||
|
||||
savingItem.RemoteEventId = calendarEvent.RemoteEventId;
|
||||
savingItem.CalendarId = assignedCalendar.Id;
|
||||
savingItem.StartDate = start;
|
||||
savingItem.DurationInSeconds = (end - start).TotalSeconds;
|
||||
savingItem.StartTimeZone = calendarEvent.StartTimeZone;
|
||||
savingItem.EndTimeZone = calendarEvent.EndTimeZone;
|
||||
savingItem.DurationInSeconds = durationInSeconds;
|
||||
savingItem.StartTimeZone = startTimeZone;
|
||||
savingItem.EndTimeZone = endTimeZone;
|
||||
savingItem.Title = calendarEvent.Title;
|
||||
savingItem.Description = calendarEvent.Description;
|
||||
savingItem.Location = calendarEvent.Location;
|
||||
@@ -158,4 +169,35 @@ public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor
|
||||
|
||||
await DeleteCalendarItemAsync(item.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string NormalizeTimeZoneId(string timeZoneId, DateTimeOffset value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(timeZoneId))
|
||||
return timeZoneId;
|
||||
|
||||
if (value != default && value.Offset == TimeSpan.Zero)
|
||||
return TimeZoneInfo.Utc.Id;
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static DateTime ConvertToEventWallClock(DateTimeOffset value, string eventTimeZoneId)
|
||||
{
|
||||
if (value == default)
|
||||
return default;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(eventTimeZoneId))
|
||||
return DateTime.SpecifyKind(value.DateTime, DateTimeKind.Unspecified);
|
||||
|
||||
try
|
||||
{
|
||||
var eventTimeZone = TimeZoneInfo.FindSystemTimeZoneById(eventTimeZoneId);
|
||||
var inEventTimeZone = TimeZoneInfo.ConvertTime(value, eventTimeZone);
|
||||
return DateTime.SpecifyKind(inEventTimeZone.DateTime, DateTimeKind.Unspecified);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DateTime.SpecifyKind(value.DateTime, DateTimeKind.Unspecified);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1768,8 +1768,11 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($"DTSTART:{item.StartDate.ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}");
|
||||
lines.Add($"DTEND:{item.EndDate.ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}");
|
||||
var startUtc = ConvertEventTimeToUtc(item.StartDate, item.StartTimeZone);
|
||||
var endUtc = ConvertEventTimeToUtc(item.EndDate, item.EndTimeZone ?? item.StartTimeZone);
|
||||
|
||||
lines.Add($"DTSTART:{startUtc:yyyyMMdd'T'HHmmss'Z'}");
|
||||
lines.Add($"DTEND:{endUtc:yyyyMMdd'T'HHmmss'Z'}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.Title))
|
||||
@@ -1827,6 +1830,23 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private static DateTime ConvertEventTimeToUtc(DateTime eventDateTime, string eventTimeZoneId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventTimeZoneId))
|
||||
return eventDateTime.ToUniversalTime();
|
||||
|
||||
try
|
||||
{
|
||||
var eventTimeZone = TimeZoneInfo.FindSystemTimeZoneById(eventTimeZoneId);
|
||||
var unspecifiedDateTime = DateTime.SpecifyKind(eventDateTime, DateTimeKind.Unspecified);
|
||||
return TimeZoneInfo.ConvertTimeToUtc(unspecifiedDateTime, eventTimeZone);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return eventDateTime.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
|
||||
private static string EscapeIcs(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
|
||||
@@ -624,8 +624,8 @@ public sealed class CalDavClient : ICalDavClient
|
||||
Location = sourceEvent.Location ?? string.Empty,
|
||||
Start = start,
|
||||
End = end,
|
||||
StartTimeZone = sourceEvent.Start?.TzId ?? string.Empty,
|
||||
EndTimeZone = sourceEvent.End?.TzId ?? string.Empty,
|
||||
StartTimeZone = ResolveTimeZoneId(sourceEvent.Start, start),
|
||||
EndTimeZone = ResolveTimeZoneId(sourceEvent.End, end),
|
||||
Recurrence = recurrence,
|
||||
OrganizerDisplayName = organizerDisplayName,
|
||||
OrganizerEmail = organizerEmail,
|
||||
@@ -638,6 +638,21 @@ public sealed class CalDavClient : ICalDavClient
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveTimeZoneId(IDateTime sourceDateTime, DateTimeOffset parsedDateTime)
|
||||
{
|
||||
var explicitTimeZoneId = sourceDateTime?.TzId;
|
||||
if (!string.IsNullOrWhiteSpace(explicitTimeZoneId))
|
||||
return explicitTimeZoneId;
|
||||
|
||||
// Explicit UTC values usually don't carry TZID in CalDAV payloads.
|
||||
// Preserve UTC so downstream local-time conversion stays correct.
|
||||
if (parsedDateTime != default && parsedDateTime.Offset == TimeSpan.Zero)
|
||||
return TimeZoneInfo.Utc.Id;
|
||||
|
||||
// Floating times without TZID should remain floating.
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string BuildRecurrenceString(CalendarEvent sourceEvent)
|
||||
{
|
||||
var recurrenceLines = new List<string>();
|
||||
|
||||
Reference in New Issue
Block a user