diff --git a/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs b/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs index b0e96cae..2337c984 100644 --- a/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs +++ b/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs @@ -143,7 +143,7 @@ private async Task> FetchAndProcessLocked( if (newSuggestions > 0 && IsInQuietHours(user, utcNow) && !await HasRecentSuggestionNotification(user.Id, utcNow, ct)) { - await CreateSuggestionNotification(user.Id, newSuggestions, ct); + await CreateSuggestionNotification(user, newSuggestions, ct); } user.MarkCalendarSyncSuccess(utcNow); @@ -258,10 +258,13 @@ private async Task HandleReconnectRequired(User user, string errorCode, Cancella { user.MarkCalendarSyncReconnectRequired(errorCode); + var isPortuguese = LocaleHelper.IsPortuguese(user.Language); var notification = Notification.Create( user.Id, - "Google Calendar disconnected", - "Auto-sync paused. Reconnect to resume.", + isPortuguese ? "Google Calendar desconectado" : "Google Calendar disconnected", + isPortuguese + ? "A sincronização automática está pausada. Reconecte para retomar." + : "Auto-sync paused. Reconnect to resume.", url: "/calendar-sync"); await deps.NotificationRepository.AddAsync(notification, ct); @@ -278,15 +281,19 @@ private async Task HasRecentSuggestionNotification(Guid userId, DateTime u ct); } - private async Task CreateSuggestionNotification(Guid userId, int count, CancellationToken ct) + private async Task CreateSuggestionNotification(User user, int count, CancellationToken ct) { - var title = count == 1 - ? "1 new calendar event" - : $"{count} new calendar events"; + var isPortuguese = LocaleHelper.IsPortuguese(user.Language); + var title = isPortuguese + ? (count == 1 ? "1 novo evento do calendário" : $"{count} novos eventos do calendário") + : (count == 1 ? "1 new calendar event" : $"{count} new calendar events"); + var body = isPortuguese + ? "Toque para revisar e importar" + : "Tap to review and import"; var notification = Notification.Create( - userId, + user.Id, title, - "Tap to review and import", + body, url: "/calendar-sync?mode=review"); await deps.NotificationRepository.AddAsync(notification, ct); } diff --git a/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs index 68886c0c..d4bdd610 100644 --- a/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs @@ -719,6 +719,116 @@ public async Task Handle_ConcurrencyConflictThenSuccess_RetryResolvesToSuccess() await _unitOfWork.Received(2).SaveChangesAsync(Arg.Any()); } + [Fact] + public async Task Handle_Success_SuggestionNotification_EnglishByDefault() + { + 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(), Arg.Any()) + .Returns(new List + { + new("evt_a", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []), + new("evt_b", "Review", null, "2026-04-11", "10:00", "11:00", true, null, []) + }); + + Notification? captured = null; + await _notificationRepo.AddAsync(Arg.Do(n => captured = n), Arg.Any()); + + await _handler.Handle(new RunCalendarAutoSyncCommand(user.Id), default); + + captured.Should().NotBeNull(); + captured!.Url.Should().Be("/calendar-sync?mode=review"); + captured.Title.Should().Be("2 new calendar events"); + captured.Body.Should().Be("Tap to review and import"); + } + + [Fact] + public async Task Handle_Success_SuggestionNotification_PortuguesePlural() + { + var user = CreateEnabledProUser(); + user.SetLanguage("pt-BR"); + StubUser(user); + _tokenService.TryRefreshAsync(user, Arg.Any()) + .Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null)); + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) + .Returns(new List + { + new("evt_a", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []), + new("evt_b", "Review", null, "2026-04-11", "10:00", "11:00", true, null, []) + }); + + Notification? captured = null; + await _notificationRepo.AddAsync(Arg.Do(n => captured = n), Arg.Any()); + + await _handler.Handle(new RunCalendarAutoSyncCommand(user.Id), default); + + captured.Should().NotBeNull(); + captured!.Title.Should().Be("2 novos eventos do calendário"); + captured.Body.Should().Be("Toque para revisar e importar"); + } + + [Fact] + public async Task Handle_Success_SuggestionNotification_PortugueseSingular() + { + var user = CreateEnabledProUser(); + user.SetLanguage("pt-BR"); + StubUser(user); + _tokenService.TryRefreshAsync(user, Arg.Any()) + .Returns(new GoogleTokenRefreshOutcome("new_access", GoogleTokenRefreshResult.Success, null)); + _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) + .Returns(new List + { + new("evt_a", "Daily standup", null, "2026-04-10", "09:00", "09:30", true, null, []) + }); + + Notification? captured = null; + await _notificationRepo.AddAsync(Arg.Do(n => captured = n), Arg.Any()); + + await _handler.Handle(new RunCalendarAutoSyncCommand(user.Id), default); + + captured.Should().NotBeNull(); + captured!.Title.Should().Be("1 novo evento do calendário"); + } + + [Fact] + public async Task Handle_ReconnectRequired_Notification_EnglishByDefault() + { + var user = CreateEnabledProUser(); + StubUser(user); + _tokenService.TryRefreshAsync(user, Arg.Any()) + .Returns(new GoogleTokenRefreshOutcome(null, GoogleTokenRefreshResult.RefreshTokenInvalid, "invalid_grant")); + + Notification? captured = null; + await _notificationRepo.AddAsync(Arg.Do(n => captured = n), Arg.Any()); + + await _handler.Handle(new RunCalendarAutoSyncCommand(user.Id), default); + + captured.Should().NotBeNull(); + captured!.Title.Should().Be("Google Calendar disconnected"); + captured.Body.Should().Be("Auto-sync paused. Reconnect to resume."); + } + + [Fact] + public async Task Handle_ReconnectRequired_Notification_Portuguese() + { + var user = CreateEnabledProUser(); + user.SetLanguage("pt-BR"); + StubUser(user); + _tokenService.TryRefreshAsync(user, Arg.Any()) + .Returns(new GoogleTokenRefreshOutcome(null, GoogleTokenRefreshResult.RefreshTokenInvalid, "invalid_grant")); + + Notification? captured = null; + await _notificationRepo.AddAsync(Arg.Do(n => captured = n), Arg.Any()); + + await _handler.Handle(new RunCalendarAutoSyncCommand(user.Id), default); + + captured.Should().NotBeNull(); + captured!.Title.Should().Be("Google Calendar desconectado"); + captured.Body.Should().Be("A sincronização automática está pausada. Reconecte para retomar."); + } + private sealed class FakeUniqueViolationException : DbException { public override string SqlState => "23505";