Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
DateTime? StartUtc = null,
string CalendarId = "",
string CalendarName = "",
DateTime? EndUtc = null)
DateTime? EndUtc = null,
string? RecurrenceTimeZone = null)
Comment thread
thomasluizon marked this conversation as resolved.
{
/// <summary>
/// The IANA zone the source calendar expands this event's recurrence in, taken from the recurring
Expand Down Expand Up @@ -241,7 +242,7 @@
IUnitOfWork unitOfWork,
ILogger<GetCalendarEventsQueryHandler> logger) : IRequestHandler<GetCalendarEventsQuery, Result<List<CalendarEventItem>>>
{
public async Task<Result<List<CalendarEventItem>>> Handle(GetCalendarEventsQuery request, CancellationToken cancellationToken)

Check warning on line 245 in src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
{
var gateCheck = await payGate.CanAccessCalendar(request.UserId, cancellationToken);
if (gateCheck.IsFailure)
Expand Down
9 changes: 7 additions & 2 deletions src/Orbit.Application/Calendar/StoredCalendarEventJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,15 @@ internal static string Serialize(CalendarEventItem item)
if (item is null)
return null;

var sourceTimeZone = ReadSourceTimeZone(stored);
return item with
{
SourceTimeZone = ReadSourceTimeZone(stored),
RecurrenceStartUtc = ReadRecurrenceStartUtc(stored)
SourceTimeZone = sourceTimeZone,
RecurrenceStartUtc = ReadRecurrenceStartUtc(stored),
RecurrenceTimeZone = item.IsRecurring && item.StartTime is not null
&& !string.IsNullOrWhiteSpace(sourceTimeZone)
? sourceTimeZone
: null
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,15 @@
ResolveStartUtc(ev.Start),
calendarId,
calendarName,
ev.End?.DateTimeDateTimeOffset?.UtcDateTime)
ev.End?.DateTimeDateTimeOffset?.UtcDateTime,
isRecurring && startTime is not null && !string.IsNullOrWhiteSpace(recurrence.TimeZone)
? recurrence.TimeZone
: null)
{
SourceTimeZone = recurrence.TimeZone,
RecurrenceStartUtc = !isRecurring
? null
: ev.RecurringEventId is not null

Check warning on line 156 in src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Extract this nested ternary operation into an independent statement.
? ev.OriginalStartTime?.DateTimeDateTimeOffset?.UtcDateTime
: ev.Start?.DateTimeDateTimeOffset?.UtcDateTime
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ public class CalendarEventItemSerializationTests
public void ResponseBody_CarriesEndUtcAndOmitsTheSourceTimeZone()
{
var json = JsonSerializer.Serialize(LisbonSeries(), ResponseOptions);
using var response = JsonDocument.Parse(json);

json.Should().NotContainEquivalentOf("sourceTimeZone");
json.Should().NotContainEquivalentOf("recurrenceStartUtc");
json.Should().NotContain("Europe/Lisbon");
json.Should().Contain("endUtc");
response.RootElement.TryGetProperty("recurrenceTimeZone", out var zone).Should().BeTrue();
zone.GetString().Should().Be("Europe/Lisbon");
}

[Fact]
Expand All @@ -34,16 +36,35 @@ public void StoredRow_CarriesTheSourceTimeZoneAndReadsItBack()
var stored = StoredCalendarEventJson.Serialize(LisbonSeries());

stored.Should().Contain("Europe/Lisbon");
var legacy = JsonNode.Parse(stored)!.AsObject();
legacy.Remove(nameof(CalendarEventItem.RecurrenceTimeZone));

var readBack = StoredCalendarEventJson.Deserialize(stored);
var readBack = StoredCalendarEventJson.Deserialize(legacy.ToJsonString());

readBack.Should().NotBeNull();
readBack!.SourceTimeZone.Should().Be("Europe/Lisbon");
readBack.RecurrenceTimeZone.Should().Be("Europe/Lisbon");
readBack.RecurrenceStartUtc.Should().Be(new DateTime(2027, 1, 7, 3, 30, 0, DateTimeKind.Utc));
readBack.Id.Should().Be("master-lisbon");
readBack.EndUtc.Should().Be(new DateTime(2027, 1, 7, 4, 0, 0, DateTimeKind.Utc));
}

[Theory]
[InlineData(true, null)]
[InlineData(false, "03:30")]
public void StoredRow_OnlyTimedRecurrencesExposeTheSourceTimeZone(bool isRecurring, string? startTime)
{
var item = LisbonSeries() with { IsRecurring = isRecurring, StartTime = startTime };
var legacy = JsonNode.Parse(StoredCalendarEventJson.Serialize(item))!.AsObject();
legacy.Remove(nameof(CalendarEventItem.RecurrenceTimeZone));

var readBack = StoredCalendarEventJson.Deserialize(legacy.ToJsonString());

readBack.Should().NotBeNull();
readBack!.SourceTimeZone.Should().Be("Europe/Lisbon");
readBack.RecurrenceTimeZone.Should().BeNull();
}

/// <summary>
/// A row written before the key existed reads back with no source zone, which the recurrence gate
/// already treats as unproved. A row whose key holds a number must read back the same way rather
Expand Down Expand Up @@ -78,7 +99,8 @@ private static CalendarEventItem LisbonSeries()
"RRULE:FREQ=DAILY;BYDAY=TH",
[],
StartUtc: new DateTime(2027, 1, 7, 3, 30, 0, DateTimeKind.Utc),
EndUtc: new DateTime(2027, 1, 7, 4, 0, 0, DateTimeKind.Utc))
EndUtc: new DateTime(2027, 1, 7, 4, 0, 0, DateTimeKind.Utc),
RecurrenceTimeZone: "Europe/Lisbon")
{
SourceTimeZone = "Europe/Lisbon",
RecurrenceStartUtc = new DateTime(2027, 1, 7, 3, 30, 0, DateTimeKind.Utc)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,47 @@ public async Task Handle_LegacyTimedSuggestionWithoutEndUtc_MatchesEventsQueryPr
result.Value[0].Event.EndTime.Should().BeNull();
}

[Fact]
public async Task Handle_LegacyRecurringSuggestion_ExposesStoredSourceTimeZone()
{
var user = User.Create("Test", "test@example.com").Value;
user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue();
_userRepo.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()).Returns(user);

var startUtc = new DateTime(2026, 4, 15, 15, 0, 0, DateTimeKind.Utc);
var eventItem = new CalendarEventItem(
"event-legacy-recurring",
"Weekly review",
null,
"2026-04-15",
"12:00",
"13:00",
true,
"RRULE:FREQ=WEEKLY;BYDAY=WE",
[],
StartUtc: startUtc,
EndUtc: startUtc.AddHours(1))
{
SourceTimeZone = "America/Sao_Paulo",
RecurrenceStartUtc = startUtc
};
var legacyJson = JsonNode.Parse(StoredCalendarEventJson.Serialize(eventItem))!.AsObject();
legacyJson.Remove(nameof(CalendarEventItem.RecurrenceTimeZone));
var suggestion = GoogleCalendarSyncSuggestion.Create(
UserId, "gcal-legacy-recurring", eventItem.Title, startUtc, legacyJson.ToJsonString(), startUtc);

_suggestionRepo.FindAsync(
Arg.Any<Expression<Func<GoogleCalendarSyncSuggestion, bool>>>(),
Arg.Any<CancellationToken>())
.Returns(new List<GoogleCalendarSyncSuggestion> { suggestion }.AsReadOnly());

var result = await _handler.Handle(new GetCalendarSyncSuggestionsQuery(UserId), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.Should().ContainSingle();
result.Value[0].Event.RecurrenceTimeZone.Should().Be("America/Sao_Paulo");
}

[Fact]
public async Task Handle_TimedSuggestionCrossingLocalMidnight_OmitsEndTime()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public async Task ByDaySeriesReadFromItsOwnZone_IsOffered()
result.IsSuccess.Should().BeTrue();
result.Value.Should().ContainSingle();
result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=DAILY;BYDAY=TH");
result.Value[0].RecurrenceTimeZone.Should().Be("Europe/Lisbon");
result.Value[0].StartDate.Should().Be("2027-01-07");
result.Value[0].StartTime.Should().Be("03:30");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using FluentAssertions;
using Google.Apis.Calendar.v3.Data;
using Microsoft.Extensions.Logging;
Expand All @@ -15,6 +16,7 @@ public class GoogleCalendarEventFetcherTests
private readonly GoogleCalendarEventFetcher _fetcher;

private const string Token = "access-token";
private static readonly JsonSerializerOptions ResponseOptions = new(JsonSerializerDefaults.Web);

private static readonly string[] SharedCalendarSelection = new[] { "shared" };
private static readonly string[] ChosenCalendarSelection = new[] { "chosen" };
Expand Down Expand Up @@ -148,7 +150,7 @@ public async Task FetchAsync_AllDayEvent_PreservesFloatingDateWithNullTimes()
{
Id = "all-day-event",
Summary = "Holiday",
Start = new EventDateTime { Date = "2026-04-15" },
Start = new EventDateTime { Date = "2026-04-15", TimeZone = "Europe/Lisbon" },
End = new EventDateTime { Date = "2026-04-16" }
});

Expand All @@ -160,6 +162,9 @@ public async Task FetchAsync_AllDayEvent_PreservesFloatingDateWithNullTimes()
result[0].EndTime.Should().BeNull();
result[0].StartUtc.Should().Be(new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc));
result[0].EndUtc.Should().BeNull("Google's all-day end date is exclusive, so it is not the end instant");
var response = JsonSerializer.SerializeToElement(result[0], ResponseOptions);
response.TryGetProperty("recurrenceTimeZone", out var zone).Should().BeTrue();
zone.ValueKind.Should().Be(JsonValueKind.Null);
}

[Fact]
Expand Down Expand Up @@ -232,6 +237,9 @@ public async Task FetchAsync_RecurringInstance_TakesTheRuleAndZoneFromTheMaster(
result[0].StartUtc.Should().Be(new DateTime(2027, 1, 7, 3, 30, 0, DateTimeKind.Utc));
result[0].RecurrenceRule.Should().Be("RRULE:FREQ=DAILY;BYDAY=TH");
result[0].SourceTimeZone.Should().Be("Europe/Lisbon");
var response = JsonSerializer.SerializeToElement(result[0], ResponseOptions);
response.TryGetProperty("recurrenceTimeZone", out var zone).Should().BeTrue();
zone.GetString().Should().Be("Europe/Lisbon");
await _api.Received(1).GetEventAsync(Token, "a", "master-lisbon", Arg.Any<CancellationToken>());
}

Expand All @@ -253,6 +261,9 @@ public async Task FetchAsync_RecurringInstance_IgnoresTheInstanceTimeZone()

result.Should().ContainSingle();
result[0].SourceTimeZone.Should().Be("Europe/Lisbon");
var response = JsonSerializer.SerializeToElement(result[0], ResponseOptions);
response.TryGetProperty("recurrenceTimeZone", out var zone).Should().BeTrue();
zone.GetString().Should().Be("Europe/Lisbon");
}

[Fact]
Expand Down
Loading