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 @@ -103,6 +103,7 @@ private async Task<Result<CalendarAutoSyncResult>> FetchAndProcess(
try
{
fetched = await deps.EventFetcher.FetchAsync(accessToken, updatedMin: null, ct);
fetched = NormalizeFetchedEvents(user.Id, fetched);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Expand Down Expand Up @@ -137,6 +138,7 @@ private async Task<int> ReconcileExistingHabits(
h => h.UserId == user.Id && h.GoogleEventId != null, ct))
.Select(h => h.GoogleEventId!)
.ToHashSet(StringComparer.Ordinal);
var reservedEventIds = new HashSet<string>(assignedEventIds, StringComparer.Ordinal);

var eventsByKey = fetched
.Where(ev => !assignedEventIds.Contains(ev.Id))
Expand Down Expand Up @@ -164,11 +166,17 @@ private async Task<int> 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);
Expand All @@ -189,13 +197,14 @@ private async Task<int> CreateSuggestions(
s => s.UserId == user.Id && s.ImportedAtUtc == null && s.DismissedAtUtc == null, ct))
.Select(s => s.GoogleEventId)
.ToHashSet(StringComparer.Ordinal);
var reservedEventIds = new HashSet<string>(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);
Expand Down Expand Up @@ -271,6 +280,34 @@ private static string BuildLegacyMatchKey(string title, string? startDate, strin
return $"{title.Trim().ToLowerInvariant()}|{startDate ?? ""}|{startTime ?? ""}";
}

private List<CalendarEventItem> NormalizeFetchedEvents(Guid userId, List<CalendarEventItem> fetched)
{
if (fetched.Count <= 1)
return fetched;

var unique = new List<CalendarEventItem>(fetched.Count);
var seenEventIds = new HashSet<string>(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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CancellationToken>())
.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<Expression<Func<Habit, bool>>>(), Arg.Any<CancellationToken>())
.Returns(new List<Habit> { firstHabit, secondHabit }.AsReadOnly());

_fetcher.FetchAsync(Arg.Any<string>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
.Returns(new List<CalendarEventItem>
{
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<CancellationToken>())
.Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null));

_fetcher.FetchAsync(Arg.Any<string>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
.Returns(new List<CalendarEventItem>
{
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<GoogleCalendarSyncSuggestion>(),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_OpportunisticSkipsDedupe()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading