From 56de3f19d40789162c360be6ada2d2d3721822a9 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 16 Apr 2026 11:38:04 -0300 Subject: [PATCH] Harden calendar auto-sync event dedupe --- .../Commands/RunCalendarAutoSyncCommand.cs | 47 +++++++++++++-- .../RunCalendarAutoSyncCommandHandlerTests.cs | 57 +++++++++++++++++++ .../GetCalendarEventsQueryHandlerTests.cs | 1 - 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs b/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs index a9d284a9..dbac07d7 100644 --- a/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs +++ b/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs @@ -103,6 +103,7 @@ private async Task> FetchAndProcess( try { fetched = await deps.EventFetcher.FetchAsync(accessToken, updatedMin: null, ct); + fetched = NormalizeFetchedEvents(user.Id, fetched); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -137,6 +138,7 @@ private async Task ReconcileExistingHabits( h => h.UserId == user.Id && h.GoogleEventId != null, ct)) .Select(h => h.GoogleEventId!) .ToHashSet(StringComparer.Ordinal); + var reservedEventIds = new HashSet(assignedEventIds, StringComparer.Ordinal); var eventsByKey = fetched .Where(ev => !assignedEventIds.Contains(ev.Id)) @@ -164,11 +166,17 @@ private async Task ReconcileExistingHabits( int reconciled = 0; foreach (var (key, habit) in habitsByKey) { - if (eventsByKey.TryGetValue(key, out var googleEventId)) + if (!eventsByKey.TryGetValue(key, out var googleEventId)) + continue; + + if (!reservedEventIds.Add(googleEventId)) { - habit.SetGoogleEventId(googleEventId); - reconciled++; + LogDuplicateReconciliationEventId(logger, user.Id, habit.Id, googleEventId); + continue; } + + habit.SetGoogleEventId(googleEventId); + reconciled++; } user.MarkCalendarSyncReconciled(utcNow); @@ -189,13 +197,14 @@ private async Task CreateSuggestions( s => s.UserId == user.Id && s.ImportedAtUtc == null && s.DismissedAtUtc == null, ct)) .Select(s => s.GoogleEventId) .ToHashSet(StringComparer.Ordinal); + var reservedEventIds = new HashSet(habitEventIds, StringComparer.Ordinal); + reservedEventIds.UnionWith(existingSuggestionEventIds); int created = 0; foreach (var ev in fetched) { if (created >= MaxSuggestionsPerTick) break; - if (habitEventIds.Contains(ev.Id)) continue; - if (existingSuggestionEventIds.Contains(ev.Id)) continue; + if (!reservedEventIds.Add(ev.Id)) continue; var startDateUtc = ParseStartDateUtc(ev); var rawJson = JsonSerializer.Serialize(ev); @@ -271,6 +280,34 @@ private static string BuildLegacyMatchKey(string title, string? startDate, strin return $"{title.Trim().ToLowerInvariant()}|{startDate ?? ""}|{startTime ?? ""}"; } + private List NormalizeFetchedEvents(Guid userId, List fetched) + { + if (fetched.Count <= 1) + return fetched; + + var unique = new List(fetched.Count); + var seenEventIds = new HashSet(StringComparer.Ordinal); + + foreach (var ev in fetched) + { + if (!seenEventIds.Add(ev.Id)) + { + LogDuplicateFetchedEventId(logger, userId, ev.Id); + continue; + } + + unique.Add(ev); + } + + return unique; + } + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Google API error during auto-sync for user {UserId}")] private static partial void LogGoogleApiError(ILogger logger, Exception ex, Guid userId); + + [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Duplicate Google Calendar event id skipped during auto-sync for user {UserId}: {GoogleEventId}")] + private static partial void LogDuplicateFetchedEventId(ILogger logger, Guid userId, string googleEventId); + + [LoggerMessage(EventId = 3, Level = LogLevel.Warning, Message = "Duplicate Google Calendar event id reconciliation skipped for user {UserId}, habit {HabitId}: {GoogleEventId}")] + private static partial void LogDuplicateReconciliationEventId(ILogger logger, Guid userId, Guid habitId, string googleEventId); } diff --git a/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs index 7d99a342..9fdf758e 100644 --- a/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs @@ -388,6 +388,63 @@ public async Task Handle_Success_ReconciliationSkipsAlreadyAssignedGoogleEventId orphanHabit.GoogleEventId.Should().BeNull(); } + [Fact] + public async Task Handle_Success_ReconciliationSkipsDuplicateFetchedEventIdsAcrossDifferentKeys() + { + var user = CreateEnabledProUser(); + StubUser(user); + _tokenService.TryRefreshAsync(user, Arg.Any()) + .Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null)); + + var firstHabit = Habit.Create(new HabitCreateParams( + user.Id, "Daily standup", FrequencyUnit.Day, 1, + DueDate: new DateOnly(2026, 4, 10), + DueTime: new TimeOnly(9, 0))).Value; + var secondHabit = Habit.Create(new HabitCreateParams( + user.Id, "Review", FrequencyUnit.Day, 1, + DueDate: new DateOnly(2026, 4, 11), + DueTime: new TimeOnly(10, 0))).Value; + + _habitRepo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) + .Returns(new List { firstHabit, secondHabit }.AsReadOnly()); + + _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List + { + new("evt_dup", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []), + new("evt_dup", "Review", null, "2026-04-11", "10:00", "11:00", true, null, []) + }); + + var result = await _handler.Handle(new RunCalendarAutoSyncCommand(user.Id), default); + + result.Value.ReconciledHabits.Should().Be(1); + firstHabit.GoogleEventId.Should().Be("evt_dup"); + secondHabit.GoogleEventId.Should().BeNull(); + } + + [Fact] + public async Task Handle_Success_CreateSuggestionsSkipsDuplicateFetchedEventIds() + { + var user = CreateEnabledProUser(); + StubUser(user); + _tokenService.TryRefreshAsync(user, Arg.Any()) + .Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null)); + + _fetcher.FetchAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new List + { + new("evt_dup", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []), + new("evt_dup", "Daily standup duplicate", null, "2026-04-10", "09:30", "10:00", true, null, []) + }); + + var result = await _handler.Handle(new RunCalendarAutoSyncCommand(user.Id), default); + + result.Value.NewSuggestions.Should().Be(1); + await _suggestionRepo.Received(1).AddAsync( + Arg.Any(), + Arg.Any()); + } + [Fact] public async Task Handle_OpportunisticSkipsDedupe() { diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index f2d0e5b9..61b6880b 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -1,7 +1,6 @@ using FluentAssertions; using Orbit.Application.Calendar.Queries; using NSubstitute; -using Orbit.Application.Calendar.Queries; using Orbit.Application.Calendar.Services; using Orbit.Application.Common; using Orbit.Domain.Common;