diff --git a/src/Orbit.Application/Challenges/Commands/CreateChallengeCommand.cs b/src/Orbit.Application/Challenges/Commands/CreateChallengeCommand.cs index 0afc074c..bbba327f 100644 --- a/src/Orbit.Application/Challenges/Commands/CreateChallengeCommand.cs +++ b/src/Orbit.Application/Challenges/Commands/CreateChallengeCommand.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using MediatR; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Orbit.Application.Common; using Orbit.Application.Social.Services; @@ -27,12 +28,13 @@ public partial class CreateChallengeCommandHandler( IGenericRepository challengeRepository, IGenericRepository habitRepository, IGenericRepository userRepository, - IPushNotificationService pushNotificationService, + IServiceScopeFactory scopeFactory, IUnitOfWork unitOfWork, ILogger logger) : IRequestHandler> { private const string JoinCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private const int JoinCodeLength = 8; + private const int MaxInvitePushConcurrency = 5; public async Task> Handle(CreateChallengeCommand request, CancellationToken cancellationToken) { @@ -90,7 +92,15 @@ private async Task NotifyInvitedFriendsAsync(User requester, IReadOnlyList return; var invitedUsers = await userRepository.FindAsync(u => invitedFriendIds.Contains(u.Id), cancellationToken); - foreach (var user in invitedUsers) + + using var throttle = new SemaphoreSlim(MaxInvitePushConcurrency); + await Task.WhenAll(invitedUsers.Select(user => NotifyOneAsync(requester, user, throttle, cancellationToken))); + } + + private async Task NotifyOneAsync(User requester, User user, SemaphoreSlim throttle, CancellationToken cancellationToken) + { + await throttle.WaitAsync(cancellationToken); + try { var isPortuguese = LocaleHelper.IsPortuguese(user.Language); var title = isPortuguese ? "Novo desafio em grupo" : "New group challenge"; @@ -98,14 +108,17 @@ private async Task NotifyInvitedFriendsAsync(User requester, IReadOnlyList ? $"{requester.Name} convidou vocĂȘ para um desafio." : $"{requester.Name} invited you to a challenge."; - try - { - await pushNotificationService.SendToUserAsync(user.Id, title, body, cancellationToken: cancellationToken); - } - catch (Exception ex) - { - LogPushNotificationFailed(logger, ex, user.Id); - } + using var scope = scopeFactory.CreateScope(); + var pushNotificationService = scope.ServiceProvider.GetRequiredService(); + await pushNotificationService.SendToUserAsync(user.Id, title, body, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + LogPushNotificationFailed(logger, ex, user.Id); + } + finally + { + throttle.Release(); } } diff --git a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs index d2d70fbb..4f090567 100644 --- a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs +++ b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -21,6 +22,9 @@ public partial class ReminderSchedulerService( private readonly TimeSpan _interval = TimeSpan.FromMinutes( configuration.GetValue("BackgroundServices:ReminderIntervalMinutes", 1)); + private readonly int _pushConcurrency = Math.Max(1, + configuration.GetValue("BackgroundServices:ReminderPushConcurrency", 8)); + public string Name => "reminder-scheduler"; public string CronExpression => "* * * * *"; @@ -45,14 +49,17 @@ internal async Task CheckAndSendReminders(CancellationToken ct) { using var scope = scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - var pushService = scope.ServiceProvider.GetRequiredService(); - await ProcessRelativeReminders(dbContext, pushService, ct); + var pending = new List(); + + await ProcessRelativeReminders(dbContext, pending, ct); - await ProcessScheduledReminders(dbContext, pushService, ct); + await ProcessScheduledReminders(dbContext, pending, ct); + + await SendPushesAsync(pending, ct); } - private async Task ProcessRelativeReminders(OrbitDbContext dbContext, IPushNotificationService pushService, CancellationToken ct) + private async Task ProcessRelativeReminders(OrbitDbContext dbContext, List pending, CancellationToken ct) { var minLocalDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)); var maxLocalDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)); @@ -98,7 +105,7 @@ private async Task ProcessRelativeReminders(OrbitDbContext dbContext, IPushNotif foreach (var habit in habits) { await ProcessSingleRelativeReminderAsync( - habit, users, loggedHabitDates, sentReminderSet, pushService, dbContext, ct); + habit, users, loggedHabitDates, sentReminderSet, pending, dbContext, ct); } } @@ -106,7 +113,7 @@ private async Task ProcessSingleRelativeReminderAsync( Habit habit, Dictionary users, HashSet<(Guid HabitId, DateOnly Date)> loggedHabitDates, HashSet<(Guid HabitId, DateOnly Date, int MinutesBefore)> sentReminderSet, - IPushNotificationService pushService, OrbitDbContext dbContext, CancellationToken ct) + List pending, OrbitDbContext dbContext, CancellationToken ct) { if (!users.TryGetValue(habit.UserId, out var user)) return; @@ -132,15 +139,17 @@ private async Task ProcessSingleRelativeReminderAsync( sentReminderSet.Add((habit.Id, userToday, minutesBefore)); - if (!await TryRecordAndSendAsync(habit, sentReminder, notification, minutesText, pushService, dbContext, ct)) + if (!await TryRecordReminderAsync(habit, sentReminder, notification, dbContext, ct)) continue; + pending.Add(new PendingReminderPush(habit.UserId, habit.Title, minutesText, habit.Id)); + if (logger.IsEnabled(LogLevel.Debug)) LogSentReminder(logger, minutesBefore, habit.Id, habit.UserId); } } - private async Task ProcessScheduledReminders(OrbitDbContext dbContext, IPushNotificationService pushService, CancellationToken ct) + private async Task ProcessScheduledReminders(OrbitDbContext dbContext, List pending, CancellationToken ct) { var minLocalDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)); var maxDayBeforeDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(2)); @@ -177,14 +186,14 @@ private async Task ProcessScheduledReminders(OrbitDbContext dbContext, IPushNoti foreach (var habit in habits) { await ProcessSingleScheduledReminderAsync( - habit, users, sentScheduledSet, pushService, dbContext, ct); + habit, users, sentScheduledSet, pending, dbContext, ct); } } private async Task ProcessSingleScheduledReminderAsync( Habit habit, Dictionary users, HashSet<(Guid HabitId, DateOnly Date, TimeOnly ReminderTimeUtc, ScheduledReminderWhen? When)> sentScheduledSet, - IPushNotificationService pushService, OrbitDbContext dbContext, CancellationToken ct) + List pending, OrbitDbContext dbContext, CancellationToken ct) { if (!users.TryGetValue(habit.UserId, out var user)) return; @@ -213,9 +222,11 @@ private async Task ProcessSingleScheduledReminderAsync( sentScheduledSet.Add((habit.Id, userToday, sr.Time, sr.When)); - if (!await TryRecordAndSendAsync(habit, sentReminder, notification, text, pushService, dbContext, ct)) + if (!await TryRecordReminderAsync(habit, sentReminder, notification, dbContext, ct)) continue; + pending.Add(new PendingReminderPush(habit.UserId, habit.Title, text, habit.Id)); + if (logger.IsEnabled(LogLevel.Debug)) LogSentScheduledReminder(logger, sr.When, sr.Time, habit.Id, habit.UserId); } @@ -231,9 +242,9 @@ private static bool ShouldSendScheduledReminder( return userTimeNow >= sr.Time; } - private async Task TryRecordAndSendAsync( - Habit habit, SentReminder sentReminder, Notification notification, string body, - IPushNotificationService pushService, OrbitDbContext dbContext, CancellationToken ct) + private async Task TryRecordReminderAsync( + Habit habit, SentReminder sentReminder, Notification notification, + OrbitDbContext dbContext, CancellationToken ct) { await dbContext.SentReminders.AddAsync(sentReminder, ct); await dbContext.Notifications.AddAsync(notification, ct); @@ -256,16 +267,48 @@ private async Task TryRecordAndSendAsync( return false; } - await pushService.SendToUserAsync(habit.UserId, habit.Title, body, "/", ct); return true; } + private async Task SendPushesAsync(IReadOnlyList pending, CancellationToken ct) + { + if (pending.Count == 0) return; + + var concurrency = Math.Min(_pushConcurrency, pending.Count); + var queue = new ConcurrentQueue(pending); + var workers = new Task[concurrency]; + for (var i = 0; i < concurrency; i++) + workers[i] = DrainPushQueueAsync(queue, ct); + + await Task.WhenAll(workers); + } + + private async Task DrainPushQueueAsync(ConcurrentQueue queue, CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var pushService = scope.ServiceProvider.GetRequiredService(); + + while (queue.TryDequeue(out var push)) + { + try + { + await pushService.SendToUserAsync(push.UserId, push.Title, push.Body, "/", ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogReminderPushFailed(logger, push.HabitId, push.UserId, ex); + } + } + } + private static void DetachPendingEntries(OrbitDbContext dbContext) { foreach (var entry in dbContext.ChangeTracker.Entries().ToList()) entry.State = EntityState.Detached; } + private readonly record struct PendingReminderPush(Guid UserId, string Title, string Body, Guid HabitId); + private static string Pluralize(string singular, int count) => count > 1 ? singular + "s" : singular; private static string FormatReminderText(int minutesBefore, string lang) @@ -318,4 +361,7 @@ private static string FormatScheduledReminderText(ScheduledReminderWhen when, st [LoggerMessage(EventId = 7, Level = LogLevel.Error, Message = "Failed to record reminder for habit {HabitId} (user {UserId}); skipping push")] private static partial void LogReminderRecordFailed(ILogger logger, Guid habitId, Guid userId, Exception ex); + [LoggerMessage(EventId = 8, Level = LogLevel.Warning, Message = "Failed to deliver reminder push for habit {HabitId} (user {UserId})")] + private static partial void LogReminderPushFailed(ILogger logger, Guid habitId, Guid userId, Exception ex); + } diff --git a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs index 933fc534..6f7b36d0 100644 --- a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs +++ b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs @@ -88,75 +88,61 @@ internal async Task ActivateMissedDayFreezes(CancellationToken ct) var completionsByUser = await LoadRecentCompletionsAsync(dbContext, candidateIds, monthFloor, ct); + var staged = new List(candidates.Count); foreach (var user in candidates) - await ProcessUserAsync(user, gamificationFreeTierEnabled, freezesByUser, guardedByUser, completionsByUser, pushService, dbContext, ct); - } - - private static async Task>> LoadRecentCompletionsAsync( - OrbitDbContext dbContext, List userIds, DateOnly since, CancellationToken ct) - { - var habitOwners = await dbContext.Habits - .Where(h => userIds.Contains(h.UserId) && !h.IsDeleted && !h.IsBadHabit) - .Select(h => new { h.Id, h.UserId }) - .ToListAsync(ct); - - var ownerByHabit = habitOwners.ToDictionary(h => h.Id, h => h.UserId); - var habitIds = habitOwners.Select(h => h.Id).ToList(); - if (habitIds.Count == 0) return new Dictionary>(); + { + var stagedFreeze = StageFreeze(user, gamificationFreeTierEnabled, freezesByUser, guardedByUser, completionsByUser, dbContext); + if (stagedFreeze is not null) + staged.Add(stagedFreeze); + } - var logs = await dbContext.HabitLogs - .Where(l => habitIds.Contains(l.HabitId) && l.Value > 0 && l.Date >= since) - .Select(l => new { l.HabitId, l.Date }) - .ToListAsync(ct); + if (staged.Count == 0) return; - var completions = new Dictionary>(); - foreach (var log in logs) + if (await TrySaveBatchAsync(dbContext, ct)) { - if (!ownerByHabit.TryGetValue(log.HabitId, out var ownerId)) continue; - if (!completions.TryGetValue(ownerId, out var dates)) - { - dates = []; - completions[ownerId] = dates; - } - dates.Add(log.Date); + await NotifyActivatedAsync(staged, pushService, ct); + return; } - return completions; + + dbContext.ChangeTracker.Clear(); + await ActivatePerUserFallbackAsync( + candidateIds, gamificationFreeTierEnabled, freezesByUser, guardedByUser, completionsByUser, pushService, dbContext, ct); } - private async Task ProcessUserAsync( + private sealed record StagedFreeze(User User, DateOnly MissedDate, string Title, string Body); + + private StagedFreeze? StageFreeze( User user, bool gamificationFreeTierEnabled, Dictionary> freezesByUser, Dictionary> guardedByUser, Dictionary> completionsByUser, - IPushNotificationService pushService, - OrbitDbContext dbContext, - CancellationToken ct) + OrbitDbContext dbContext) { - if (!user.HasProAccess && !gamificationFreeTierEnabled) return; + if (!user.HasProAccess && !gamificationFreeTierEnabled) return null; var tz = TimeZoneHelper.FindTimeZone(user.TimeZone, logger, user.Id); var userToday = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz)); var missedDate = userToday.AddDays(-1); - if (user.LastActiveDate is null || user.LastActiveDate >= missedDate) return; + if (user.LastActiveDate is null || user.LastActiveDate >= missedDate) return null; var existingFreezes = freezesByUser.GetValueOrDefault(user.Id) ?? []; - if (existingFreezes.Any(f => f.UsedOnDate == missedDate)) return; + if (existingFreezes.Any(f => f.UsedOnDate == missedDate)) return null; var guardedDates = guardedByUser.GetValueOrDefault(user.Id) ?? []; - if (guardedDates.Contains(missedDate)) return; + if (guardedDates.Contains(missedDate)) return null; var completions = completionsByUser.GetValueOrDefault(user.Id) ?? []; - if (completions.Contains(missedDate)) return; + if (completions.Contains(missedDate)) return null; var monthStart = new DateOnly(missedDate.Year, missedDate.Month, 1); var monthEnd = monthStart.AddMonths(1); var freezesThisMonth = existingFreezes.Count(f => f.UsedOnDate >= monthStart && f.UsedOnDate < monthEnd); - if (freezesThisMonth >= AppConstants.MaxStreakFreezesPerMonth) return; + if (freezesThisMonth >= AppConstants.MaxStreakFreezesPerMonth) return null; var consume = user.ConsumeStreakFreeze(); - if (consume.IsFailure) return; + if (consume.IsFailure) return null; dbContext.StreakFreezes.Add(StreakFreeze.Create(user.Id, missedDate)); dbContext.SentStreakFreezeAlerts.Add(SentStreakFreezeAlert.Create(user.Id, missedDate)); @@ -164,13 +150,104 @@ private async Task ProcessUserAsync( var (title, body) = BuildNotification(user.CurrentStreak, user.Language ?? "en"); dbContext.Notifications.Add(Notification.Create(user.Id, title, body, StreakUrl)); - if (!await TrySaveUserFreezeAsync(user.Id, dbContext, ct)) - return; + return new StagedFreeze(user, missedDate, title, body); + } + + private async Task TrySaveBatchAsync(OrbitDbContext dbContext, CancellationToken ct) + { + try + { + await dbContext.SaveChangesAsync(ct); + return true; + } + catch (DbUpdateConcurrencyException) + { + return false; + } + catch (DbUpdateException ex) when (DbUniqueViolation.IsUniqueViolation(ex)) + { + return false; + } + } + + private async Task NotifyActivatedAsync( + List staged, IPushNotificationService pushService, CancellationToken ct) + { + foreach (var freeze in staged) + await NotifyFreezeActivatedAsync(freeze, pushService, ct); + } - await pushService.SendToUserAsync(user.Id, title, body, StreakUrl, ct); + private async Task NotifyFreezeActivatedAsync( + StagedFreeze freeze, IPushNotificationService pushService, CancellationToken ct) + { + try + { + await pushService.SendToUserAsync(freeze.User.Id, freeze.Title, freeze.Body, StreakUrl, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogFreezePushFailed(logger, freeze.User.Id, ex); + } if (logger.IsEnabled(LogLevel.Information)) - LogFreezeActivated(logger, user.Id, missedDate); + LogFreezeActivated(logger, freeze.User.Id, freeze.MissedDate); + } + + private async Task ActivatePerUserFallbackAsync( + List candidateIds, + bool gamificationFreeTierEnabled, + Dictionary> freezesByUser, + Dictionary> guardedByUser, + Dictionary> completionsByUser, + IPushNotificationService pushService, + OrbitDbContext dbContext, + CancellationToken ct) + { + var users = await dbContext.Users + .Where(u => candidateIds.Contains(u.Id)) + .ToListAsync(ct); + + foreach (var user in users) + { + var staged = StageFreeze(user, gamificationFreeTierEnabled, freezesByUser, guardedByUser, completionsByUser, dbContext); + if (staged is null) continue; + + if (!await TrySaveUserFreezeAsync(user.Id, dbContext, ct)) + continue; + + await NotifyFreezeActivatedAsync(staged, pushService, ct); + } + } + + private static async Task>> LoadRecentCompletionsAsync( + OrbitDbContext dbContext, List userIds, DateOnly since, CancellationToken ct) + { + var habitOwners = await dbContext.Habits + .Where(h => userIds.Contains(h.UserId) && !h.IsDeleted && !h.IsBadHabit) + .Select(h => new { h.Id, h.UserId }) + .ToListAsync(ct); + + var ownerByHabit = habitOwners.ToDictionary(h => h.Id, h => h.UserId); + var habitIds = habitOwners.Select(h => h.Id).ToList(); + if (habitIds.Count == 0) return new Dictionary>(); + + var logs = await dbContext.HabitLogs + .Where(l => habitIds.Contains(l.HabitId) && l.Value > 0 && l.Date >= since) + .Select(l => new { l.HabitId, l.Date }) + .ToListAsync(ct); + + var completions = new Dictionary>(); + foreach (var log in logs) + { + if (!ownerByHabit.TryGetValue(log.HabitId, out var ownerId)) continue; + if (!completions.TryGetValue(ownerId, out var dates)) + { + dates = []; + completions[ownerId] = dates; + } + dates.Add(log.Date); + } + return completions; } private async Task TrySaveUserFreezeAsync(Guid userId, OrbitDbContext dbContext, CancellationToken ct) @@ -236,4 +313,7 @@ internal static (string Title, string Body) BuildNotification(int currentStreak, [LoggerMessage(EventId = 6, Level = LogLevel.Information, Message = "Streak freeze skipped for user {UserId} due to a concurrent update; will re-evaluate next run")] private static partial void LogFreezeConflictSkipped(ILogger logger, Guid userId); + + [LoggerMessage(EventId = 7, Level = LogLevel.Warning, Message = "Failed to deliver streak-freeze push for user {UserId}; freeze already persisted")] + private static partial void LogFreezePushFailed(ILogger logger, Guid userId, Exception ex); } diff --git a/tests/Orbit.Application.Tests/Commands/Challenges/CreateChallengeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Challenges/CreateChallengeCommandHandlerTests.cs index acf92cfb..e28acce8 100644 --- a/tests/Orbit.Application.Tests/Commands/Challenges/CreateChallengeCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Challenges/CreateChallengeCommandHandlerTests.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NSubstitute; using Orbit.Application.Challenges.Commands; @@ -35,9 +36,13 @@ public CreateChallengeCommandHandlerTests() { var guard = new SocialAccessGuard(_userRepository); var friendGraph = new FriendGraphService(_userRepository, _friendshipRepository, _blockedUserRepository); + var scopeFactory = new ServiceCollection() + .AddSingleton(_push) + .BuildServiceProvider() + .GetRequiredService(); _handler = new CreateChallengeCommandHandler( guard, friendGraph, _challengeRepository, _habitRepository, _userRepository, - _push, _unitOfWork, Substitute.For>()); + scopeFactory, _unitOfWork, Substitute.For>()); SocialTestHelpers.StubUsers(_userRepository, _creator, _friend); SocialTestHelpers.StubFind(_friendshipRepository, AcceptedFriendship()); @@ -83,6 +88,37 @@ await _push.Received(1).SendToUserAsync( _friend.Id, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } + [Fact] + public async Task ManyInvitedFriends_PushesEachExactlyOnce_AcrossThrottledFanOut() + { + var friends = Enumerable.Range(0, 8) + .Select(index => SocialTestHelpers.OptedInUser($"Friend-{index}")) + .ToList(); + + SocialTestHelpers.StubUsers(_userRepository, [_creator, .. friends]); + SocialTestHelpers.StubFind(_friendshipRepository, friends.Select(AcceptedFriendshipWith).ToArray()); + SocialTestHelpers.StubFind(_blockedUserRepository); + + var result = await _handler.Handle(Command(invited: friends.Select(f => f.Id).ToList()), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + foreach (var friend in friends) + { + await _push.Received(1).SendToUserAsync( + friend.Id, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + await _push.Received(friends.Count).SendToUserAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + private Friendship AcceptedFriendshipWith(User friend) + { + var friendship = Friendship.Create(_creator.Id, friend.Id).Value; + friendship.Accept(); + return friendship; + } + [Fact] public async Task NoInvitedFriends_DoesNotPush() { diff --git a/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs index 09b10873..c9efe5cb 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/ReminderSchedulerServiceTests.cs @@ -568,6 +568,71 @@ await pushService.Received(1).SendToUserAsync( user.Id, habit.Title, Arg.Any(), "/", Arg.Any()); } + [Fact] + public async Task CheckAndSendReminders_ManyDueReminders_DeliversEachRecipientExactlyOnce() + { + await using var dbContext = CreateInMemoryDbContext(); + var pushService = Substitute.For(); + + var users = SeedDueRelativeReminders(dbContext, count: 6); + await dbContext.SaveChangesAsync(); + + var service = CreateService(dbContext, pushService); + await service.CheckAndSendReminders(CancellationToken.None); + + (await dbContext.SentReminders.CountAsync()).Should().Be(users.Count); + foreach (var user in users) + { + await pushService.Received(1).SendToUserAsync( + user.Id, Arg.Any(), Arg.Any(), "/", Arg.Any()); + } + await pushService.Received(users.Count).SendToUserAsync( + Arg.Any(), Arg.Any(), Arg.Any(), "/", Arg.Any()); + } + + [Fact] + public async Task CheckAndSendReminders_OneRecipientPushThrows_OthersStillRecordedAndDelivered() + { + await using var dbContext = CreateInMemoryDbContext(); + var pushService = Substitute.For(); + + var users = SeedDueRelativeReminders(dbContext, count: 4); + await dbContext.SaveChangesAsync(); + + pushService.SendToUserAsync( + users[0].Id, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("push down"))); + + var service = CreateService(dbContext, pushService); + await service.CheckAndSendReminders(CancellationToken.None); + + (await dbContext.SentReminders.CountAsync()).Should().Be(users.Count); + foreach (var user in users.Skip(1)) + { + await pushService.Received(1).SendToUserAsync( + user.Id, Arg.Any(), Arg.Any(), "/", Arg.Any()); + } + } + + private static List SeedDueRelativeReminders(OrbitDbContext dbContext, int count) + { + var users = new List(count); + for (var index = 0; index < count; index++) + { + var user = User.Create($"User{index}", $"user{index}@test.com").Value; + var habit = Habit.Create(new HabitCreateParams( + user.Id, $"Workout {index}", FrequencyUnit.Day, 1, + ReminderEnabled: true, + DueDate: UtcToday, + DueTime: new TimeOnly(0, 0), + ReminderTimes: new[] { 0 })).Value; + dbContext.Users.Add(user); + dbContext.Habits.Add(habit); + users.Add(user); + } + return users; + } + private static OrbitDbContext CreateInMemoryDbContext() => new(new DbContextOptionsBuilder() .UseInMemoryDatabase($"ReminderSchedulerServiceTests_{Guid.NewGuid()}") diff --git a/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs index 1e18d74b..40eca120 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/StreakFreezeAutoActivationServiceTests.cs @@ -374,6 +374,60 @@ public async Task ActivateMissedDayFreezes_FreeUser_FlagOn_ActivatesFreeze() user.StreakFreezesAccumulated.Should().Be(0); } + [Fact] + public async Task ActivateMissedDayFreezes_MultipleEligibleUsers_PersistsAllInOneBatchedSave() + { + var users = Enumerable.Range(0, 4).Select(_ => CreateEligibleProUser()).ToList(); + + var interceptor = new CountingSaveChangesInterceptor(); + await using var dbContext = CreateInterceptingContext(interceptor); + var pushService = Substitute.For(); + + dbContext.Users.AddRange(users); + await dbContext.SaveChangesAsync(); + interceptor.Reset(); + + var service = CreateService(dbContext, pushService); + await service.ActivateMissedDayFreezes(CancellationToken.None); + + interceptor.SaveCount.Should().Be(1); + foreach (var user in users) + { + (await dbContext.StreakFreezes.AsNoTracking().CountAsync(f => f.UserId == user.Id)).Should().Be(1); + user.StreakFreezesAccumulated.Should().Be(0); + await pushService.Received(1).SendToUserAsync( + user.Id, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + } + + [Fact] + public async Task ActivateMissedDayFreezes_OnePushThrows_OtherStagedUsersStillNotifiedAndAllFrozen() + { + var users = Enumerable.Range(0, 4).Select(_ => CreateEligibleProUser()).ToList(); + + await using var dbContext = CreateInMemoryDbContext(); + var pushService = Substitute.For(); + + pushService.SendToUserAsync( + users[0].Id, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("push down"))); + + dbContext.Users.AddRange(users); + await dbContext.SaveChangesAsync(); + + var service = CreateService(dbContext, pushService); + await service.ActivateMissedDayFreezes(CancellationToken.None); + + foreach (var user in users) + (await dbContext.StreakFreezes.AsNoTracking().CountAsync(f => f.UserId == user.Id)).Should().Be(1); + + foreach (var user in users.Skip(1)) + { + await pushService.Received(1).SendToUserAsync( + user.Id, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + } + private static User CreateEligibleProUser() { var user = User.Create($"User-{Guid.NewGuid():N}", $"{Guid.NewGuid():N}@test.com").Value; @@ -438,6 +492,22 @@ private sealed class FakeUniqueViolationException : DbException public override string SqlState => "23505"; } + private sealed class CountingSaveChangesInterceptor : SaveChangesInterceptor + { + private int _count; + + public int SaveCount => _count; + + public void Reset() => _count = 0; + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _count); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + } + private sealed class ThrowConcurrencyForUserInterceptor(Guid conflictUserId) : SaveChangesInterceptor { public override ValueTask> SavingChangesAsync(