From 83618551db2f94e569bb1ae51da3d9a0eef82e96 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Tue, 25 Aug 2026 17:20:14 -0300 Subject: [PATCH 1/5] chore: initialize ticket 366 From 0f966d3bf9a1ec3214e8c25204f014c232213c19 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Tue, 25 Aug 2026 17:45:50 -0300 Subject: [PATCH 2/5] feat: notify users when monthly periods close --- ...viceCollectionExtensions.BackgroundJobs.cs | 7 + src/Orbit.Api/appsettings.json | 2 + .../Notifications/NotificationUrls.cs | 3 + .../PeriodCloseNotificationService.cs | 226 ++++++++++++++++ .../Notifications/NotificationUrlsTests.cs | 8 + .../ScheduledJobRegistryTests.cs | 42 ++- .../PeriodCloseNotificationServiceTests.cs | 255 ++++++++++++++++++ 7 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 src/Orbit.Infrastructure/Services/PeriodCloseNotificationService.cs create mode 100644 tests/Orbit.Infrastructure.Tests/Services/PeriodCloseNotificationServiceTests.cs diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs index b57a6ef1..b31b123b 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.BackgroundJobs.cs @@ -42,6 +42,8 @@ internal static void AddInProcessSchedulers(WebApplicationBuilder builder) { builder.Services.AddHostedService(); builder.Services.AddHostedService(); + if (IsPeriodCloseNotificationEnabled(builder.Configuration)) + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -93,6 +95,8 @@ internal static void AddDurableRecurringJobs(WebApplicationBuilder builder) builder.Services.AddSingleton(); AddScheduledJob(builder); AddScheduledJob(builder); + if (IsPeriodCloseNotificationEnabled(builder.Configuration)) + AddScheduledJob(builder); AddScheduledJob(builder); AddScheduledJob(builder); AddScheduledJob(builder); @@ -122,4 +126,7 @@ private static void AddScheduledJob(WebApplicationBuilder builder) /// internal static bool IsStreakFreezeAutoActivationEnabled(IConfiguration configuration) => configuration.GetValue("BackgroundServices:StreakFreezeAutoActivationEnabled", true); + + internal static bool IsPeriodCloseNotificationEnabled(IConfiguration configuration) => + configuration.GetValue("BackgroundServices:PeriodCloseNotificationEnabled", false); } diff --git a/src/Orbit.Api/appsettings.json b/src/Orbit.Api/appsettings.json index a1807772..d2be27af 100644 --- a/src/Orbit.Api/appsettings.json +++ b/src/Orbit.Api/appsettings.json @@ -119,6 +119,8 @@ "BackgroundServices": { "ReminderIntervalMinutes": 1, "GoalDeadlineIntervalMinutes": 30, + "PeriodCloseNotificationEnabled": false, + "PeriodCloseNotificationIntervalMinutes": 30, "SlipAlertIntervalMinutes": 5, "ProactiveCheckinIntervalMinutes": 60, "ProactiveCheckinHour": 19, diff --git a/src/Orbit.Application/Notifications/NotificationUrls.cs b/src/Orbit.Application/Notifications/NotificationUrls.cs index 80f3e2fe..1172bf00 100644 --- a/src/Orbit.Application/Notifications/NotificationUrls.cs +++ b/src/Orbit.Application/Notifications/NotificationUrls.cs @@ -8,4 +8,7 @@ public static class NotificationUrls public const string Profile = "/profile"; public const string CalendarSync = "/calendar-sync"; public const string CalendarSyncReview = "/calendar-sync?mode=review"; + + public static string WrappedClosedMonth(int year, int month) => + $"/progress?wrapped=month&year={year}&month={month}"; } diff --git a/src/Orbit.Infrastructure/Services/PeriodCloseNotificationService.cs b/src/Orbit.Infrastructure/Services/PeriodCloseNotificationService.cs new file mode 100644 index 00000000..182ab5a5 --- /dev/null +++ b/src/Orbit.Infrastructure/Services/PeriodCloseNotificationService.cs @@ -0,0 +1,226 @@ +using System.Globalization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Orbit.Application.Common; +using Orbit.Application.Notifications; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.BackgroundJobs; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services.Hosting; + +namespace Orbit.Infrastructure.Services; + +public partial class PeriodCloseNotificationService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IConfiguration configuration) : ScheduledServiceBase, IScheduledJob +{ + private readonly TimeSpan _interval = TimeSpan.FromMinutes( + configuration.GetValue("BackgroundServices:PeriodCloseNotificationIntervalMinutes", 30)); + + public string Name => "period-close-notification"; + + public string CronExpression => "*/30 * * * *"; + + public Task RunAsync(CancellationToken cancellationToken) => ExecuteTickAsync(cancellationToken); + + protected override TimeSpan Interval => _interval; + + protected override async Task ExecuteTickAsync(CancellationToken stoppingToken) + { + await CheckAndSendNotificationsAsync(stoppingToken); + BackgroundServiceHealthCheck.RecordTick("PeriodCloseNotification"); + } + + protected override void LogStarted() => LogServiceStarted(logger); + + protected override void LogStopped() => LogServiceStopped(logger); + + protected override void LogTickError(Exception ex) => LogServiceError(logger, ex); + + internal async Task CheckAndSendNotificationsAsync(CancellationToken cancellationToken) + { + using var scope = scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var pushService = scope.ServiceProvider.GetRequiredService(); + var userDateService = scope.ServiceProvider.GetRequiredService(); + + var subscribedUsers = await dbContext.Users + .AsNoTracking() + .Where(user => !user.IsDeactivated + && dbContext.PushSubscriptions.Any(subscription => subscription.UserId == user.Id)) + .Select(user => new SubscribedUser(user.Id, user.TimeZone, user.Language)) + .ToListAsync(cancellationToken); + + var boundaryUsers = new List(); + foreach (var user in subscribedUsers) + { + var userToday = await userDateService.GetUserTodayAsync( + user.TimeZone, + user.Id, + cancellationToken); + if (userToday.Day != 1) + continue; + + var closedMonth = userToday.AddMonths(-1); + boundaryUsers.Add(new BoundaryUser(user.Id, user.Language, closedMonth.Year, closedMonth.Month)); + } + + foreach (var monthGroup in boundaryUsers.GroupBy(user => new { user.Year, user.Month })) + { + await ProcessClosedMonthAsync( + monthGroup.ToList(), + monthGroup.Key.Year, + monthGroup.Key.Month, + dbContext, + pushService, + cancellationToken); + } + } + + private async Task ProcessClosedMonthAsync( + List users, + int year, + int month, + OrbitDbContext dbContext, + IPushNotificationService pushService, + CancellationToken cancellationToken) + { + var dateFrom = new DateOnly(year, month, 1); + var dateTo = new DateOnly(year, month, DateTime.DaysInMonth(year, month)); + var userIds = users.Select(user => user.Id).ToList(); + + var activeUserIds = (await dbContext.Habits + .IgnoreQueryFilters() + .AsNoTracking() + .Where(habit => userIds.Contains(habit.UserId) + && habit.Logs.Any(log => !log.IsDeleted + && log.Value > 0 + && log.Date >= dateFrom + && log.Date <= dateTo)) + .Select(habit => habit.UserId) + .Distinct() + .ToListAsync(cancellationToken)) + .ToHashSet(); + + var activeUsers = users.Where(user => activeUserIds.Contains(user.Id)).ToList(); + if (activeUsers.Count == 0) + return; + + var dedupeKeys = activeUsers + .Select(user => BuildDedupeKey(user.Id, year, month)) + .ToList(); + var sentKeys = (await dbContext.Notifications + .Where(notification => dedupeKeys.Contains(notification.DedupeKey!)) + .Select(notification => notification.DedupeKey!) + .ToListAsync(cancellationToken)) + .ToHashSet(); + + foreach (var user in activeUsers) + { + var dedupeKey = BuildDedupeKey(user.Id, year, month); + if (!sentKeys.Add(dedupeKey)) + continue; + + try + { + await TryRecordAndSendAsync( + user, + year, + month, + dedupeKey, + dbContext, + pushService, + cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogUserProcessingFailed(logger, user.Id, year, month, ex); + } + } + } + + private async Task TryRecordAndSendAsync( + BoundaryUser user, + int year, + int month, + string dedupeKey, + OrbitDbContext dbContext, + IPushNotificationService pushService, + CancellationToken cancellationToken) + { + var (title, body) = BuildNotification(month, user.Language); + var url = NotificationUrls.WrappedClosedMonth(year, month); + var notification = Notification.Create( + user.Id, + title, + body, + url, + dedupeKey: dedupeKey); + await dbContext.Notifications.AddAsync(notification, cancellationToken); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (DbUniqueViolation.IsUniqueViolation(ex)) + { + dbContext.Entry(notification).State = EntityState.Detached; + if (logger.IsEnabled(LogLevel.Debug)) + LogNotificationAlreadyRecorded(logger, user.Id, year, month); + return; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + dbContext.Entry(notification).State = EntityState.Detached; + LogNotificationRecordFailed(logger, user.Id, year, month, ex); + return; + } + + await pushService.SendToUserAsync(user.Id, title, body, url, cancellationToken); + if (logger.IsEnabled(LogLevel.Debug)) + LogNotificationSent(logger, user.Id, year, month); + } + + internal static string BuildDedupeKey(Guid userId, int year, int month) => + $"wrapped-{userId}-{year}-{month:D2}"; + + internal static (string Title, string Body) BuildNotification(int month, string? language) + { + var isPortuguese = LocaleHelper.IsPortuguese(language); + var culture = CultureInfo.GetCultureInfo(isPortuguese ? "pt-BR" : "en-US"); + var monthName = culture.TextInfo.ToTitleCase(culture.DateTimeFormat.GetMonthName(month)); + + return isPortuguese + ? ("Seu Wrapped está pronto", $"{monthName} fechou - veja como foi o seu mês.") + : ("Your Wrapped is ready", $"{monthName} is closed - see how your month went."); + } + + private sealed record SubscribedUser(Guid Id, string? TimeZone, string? Language); + + private sealed record BoundaryUser(Guid Id, string? Language, int Year, int Month); + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "PeriodCloseNotificationService started")] + private static partial void LogServiceStarted(ILogger logger); + + [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "PeriodCloseNotificationService stopped")] + private static partial void LogServiceStopped(ILogger logger); + + [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Error in period close notification service")] + private static partial void LogServiceError(ILogger logger, Exception ex); + + [LoggerMessage(EventId = 4, Level = LogLevel.Debug, Message = "Sent closed month notification for user {UserId} and period {Year}-{Month}")] + private static partial void LogNotificationSent(ILogger logger, Guid userId, int year, int month); + + [LoggerMessage(EventId = 5, Level = LogLevel.Debug, Message = "Closed month notification already recorded for user {UserId} and period {Year}-{Month}")] + private static partial void LogNotificationAlreadyRecorded(ILogger logger, Guid userId, int year, int month); + + [LoggerMessage(EventId = 6, Level = LogLevel.Error, Message = "Failed to record closed month notification for user {UserId} and period {Year}-{Month}")] + private static partial void LogNotificationRecordFailed(ILogger logger, Guid userId, int year, int month, Exception ex); + + [LoggerMessage(EventId = 7, Level = LogLevel.Error, Message = "Failed to process closed month notification for user {UserId} and period {Year}-{Month}")] + private static partial void LogUserProcessingFailed(ILogger logger, Guid userId, int year, int month, Exception ex); +} diff --git a/tests/Orbit.Application.Tests/Notifications/NotificationUrlsTests.cs b/tests/Orbit.Application.Tests/Notifications/NotificationUrlsTests.cs index dfd22b56..9274d438 100644 --- a/tests/Orbit.Application.Tests/Notifications/NotificationUrlsTests.cs +++ b/tests/Orbit.Application.Tests/Notifications/NotificationUrlsTests.cs @@ -36,4 +36,12 @@ public void Member_HasExpectedRoute(string member, string actual, string expecte member.Should().NotBeNullOrWhiteSpace(); actual.Should().Be(expected); } + + [Fact] + public void WrappedClosedMonth_CarriesAddressablePeriod() + { + var url = NotificationUrls.WrappedClosedMonth(2026, 2); + + url.Should().Be("/progress?wrapped=month&year=2026&month=2"); + } } diff --git a/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRegistryTests.cs b/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRegistryTests.cs index da867afe..4263fe65 100644 --- a/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRegistryTests.cs +++ b/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRegistryTests.cs @@ -54,9 +54,9 @@ public void JobNames_AreUnique() } [Fact] - public void AllFourteenRecurringSchedulers_AreRegisteredAsJobs() + public void AllFifteenRecurringSchedulers_AreRegisteredAsJobs() { - BuildAll().Should().HaveCount(14); + BuildAll().Should().HaveCount(15); } [Theory] @@ -96,6 +96,43 @@ public void DurableRegistration_RespectsDefaultOnStreakFreezeFlag( .Should().Be(expectedRegistered); } + [Theory] + [InlineData(null, false)] + [InlineData("true", true)] + [InlineData("false", false)] + public void InProcessRegistration_RespectsDefaultOffPeriodCloseFlag( + string? configuredValue, + bool expectedRegistered) + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration["BackgroundServices:PeriodCloseNotificationEnabled"] = configuredValue; + + OrbitServiceCollectionExtensions.AddInProcessSchedulers(builder); + + builder.Services.Any(descriptor => + descriptor.ServiceType == typeof(IHostedService) + && descriptor.ImplementationType == typeof(PeriodCloseNotificationService)) + .Should().Be(expectedRegistered); + } + + [Theory] + [InlineData(null, false)] + [InlineData("true", true)] + [InlineData("false", false)] + public void DurableRegistration_RespectsDefaultOffPeriodCloseFlag( + string? configuredValue, + bool expectedRegistered) + { + var builder = WebApplication.CreateBuilder(); + builder.Configuration["BackgroundServices:PeriodCloseNotificationEnabled"] = configuredValue; + + OrbitServiceCollectionExtensions.AddDurableRecurringJobs(builder); + + builder.Services.Any(descriptor => + descriptor.ServiceType == typeof(PeriodCloseNotificationService)) + .Should().Be(expectedRegistered); + } + [Fact] public async Task RunAsync_ExecutesUnderlyingScan_WithoutDoubleRunningSideEffects() { @@ -116,6 +153,7 @@ private static List BuildAll() => [ new ReminderSchedulerService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), new GoalDeadlineNotificationService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), + new PeriodCloseNotificationService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), new SlipAlertSchedulerService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), new ProactiveCheckinSchedulerService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), new AccountDeletionService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), diff --git a/tests/Orbit.Infrastructure.Tests/Services/PeriodCloseNotificationServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/PeriodCloseNotificationServiceTests.cs new file mode 100644 index 00000000..1a41e75f --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Services/PeriodCloseNotificationServiceTests.cs @@ -0,0 +1,255 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Orbit.Application.Notifications; +using Orbit.Domain.Entities; +using Orbit.Domain.Enums; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.Services; + +public class PeriodCloseNotificationServiceTests +{ + private static readonly DateOnly ClosedMonthLogDate = new(2026, 1, 15); + private static readonly DateOnly FirstDayAfterClosedMonth = new(2026, 2, 1); + + [Fact] + public async Task CheckAndSendNotifications_ActiveUserAtBoundary_RecordsLocalizedPeriodAndPushes() + { + await using var dbContext = CreateDbContext(); + var user = await SeedUserAsync(dbContext, withActivity: true, withSubscription: true); + var pushService = Substitute.For(); + var userDateService = Substitute.For(); + ConfigureToday(userDateService, FirstDayAfterClosedMonth); + var service = CreateService(dbContext, pushService, userDateService); + + await service.CheckAndSendNotificationsAsync(CancellationToken.None); + + var expectedUrl = NotificationUrls.WrappedClosedMonth(2026, 1); + var notification = await dbContext.Notifications.SingleAsync(); + notification.UserId.Should().Be(user.Id); + notification.Title.Should().Be("Your Wrapped is ready"); + notification.Body.Should().Be("January is closed - see how your month went."); + notification.Url.Should().Be(expectedUrl); + notification.DedupeKey.Should().Be(PeriodCloseNotificationService.BuildDedupeKey(user.Id, 2026, 1)); + await pushService.Received(1).SendToUserAsync( + user.Id, + notification.Title, + notification.Body, + expectedUrl, + Arg.Any()); + } + + [Fact] + public async Task CheckAndSendNotifications_RunTwice_RecordsAndPushesOnce() + { + await using var dbContext = CreateDbContext(); + var user = await SeedUserAsync(dbContext, withActivity: true, withSubscription: true); + var pushService = Substitute.For(); + var userDateService = Substitute.For(); + ConfigureToday(userDateService, FirstDayAfterClosedMonth); + var service = CreateService(dbContext, pushService, userDateService); + + await service.CheckAndSendNotificationsAsync(CancellationToken.None); + await service.CheckAndSendNotificationsAsync(CancellationToken.None); + + (await dbContext.Notifications.CountAsync()).Should().Be(1); + await pushService.Received(1).SendToUserAsync( + user.Id, + Arg.Any(), + Arg.Any(), + NotificationUrls.WrappedClosedMonth(2026, 1), + Arg.Any()); + } + + [Fact] + public async Task CheckAndSendNotifications_EmptyClosedMonth_DoesNothing() + { + await using var dbContext = CreateDbContext(); + await SeedUserAsync(dbContext, withActivity: false, withSubscription: true); + var pushService = Substitute.For(); + var userDateService = Substitute.For(); + ConfigureToday(userDateService, FirstDayAfterClosedMonth); + var service = CreateService(dbContext, pushService, userDateService); + + await service.CheckAndSendNotificationsAsync(CancellationToken.None); + + (await dbContext.Notifications.CountAsync()).Should().Be(0); + await pushService.DidNotReceive().SendToUserAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task CheckAndSendNotifications_DifferentLocalBoundaries_NotifiesUsersOnSeparateRuns() + { + await using var dbContext = CreateDbContext(); + var firstUser = await SeedUserAsync( + dbContext, + withActivity: true, + withSubscription: true, + name: "First", + timeZone: "Pacific/Auckland"); + var secondUser = await SeedUserAsync( + dbContext, + withActivity: true, + withSubscription: true, + name: "Second", + timeZone: "America/Sao_Paulo"); + var localDates = new Dictionary + { + [firstUser.Id] = FirstDayAfterClosedMonth, + [secondUser.Id] = FirstDayAfterClosedMonth.AddDays(-1) + }; + var pushService = Substitute.For(); + var userDateService = Substitute.For(); + userDateService.GetUserTodayAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(call => Task.FromResult(localDates[call.ArgAt(1)])); + var service = CreateService(dbContext, pushService, userDateService); + + await service.CheckAndSendNotificationsAsync(CancellationToken.None); + + await pushService.Received(1).SendToUserAsync( + firstUser.Id, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + await pushService.DidNotReceive().SendToUserAsync( + secondUser.Id, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + + localDates[firstUser.Id] = FirstDayAfterClosedMonth.AddDays(1); + localDates[secondUser.Id] = FirstDayAfterClosedMonth; + await service.CheckAndSendNotificationsAsync(CancellationToken.None); + + await pushService.Received(1).SendToUserAsync( + secondUser.Id, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + (await dbContext.Notifications.CountAsync()).Should().Be(2); + } + + [Fact] + public async Task CheckAndSendNotifications_UserWithoutPushSubscription_DoesNothing() + { + await using var dbContext = CreateDbContext(); + await SeedUserAsync(dbContext, withActivity: true, withSubscription: false); + var pushService = Substitute.For(); + var userDateService = Substitute.For(); + ConfigureToday(userDateService, FirstDayAfterClosedMonth); + var service = CreateService(dbContext, pushService, userDateService); + + await service.CheckAndSendNotificationsAsync(CancellationToken.None); + + (await dbContext.Notifications.CountAsync()).Should().Be(0); + await userDateService.DidNotReceive().GetUserTodayAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await pushService.DidNotReceive().SendToUserAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public void BuildNotification_Portuguese_UsesLocalizedMonthNameAndCopy() + { + var notification = PeriodCloseNotificationService.BuildNotification(3, "pt-BR"); + + notification.Title.Should().Be("Seu Wrapped está pronto"); + notification.Body.Should().Be("Março fechou - veja como foi o seu mês."); + } + + [Fact] + public void BuildDedupeKey_IncludesUserAndZeroPaddedMonth() + { + var userId = Guid.Parse("d88f532c-49bb-4452-b46a-350e3460a03f"); + + var key = PeriodCloseNotificationService.BuildDedupeKey(userId, 2026, 2); + + key.Should().Be("wrapped-d88f532c-49bb-4452-b46a-350e3460a03f-2026-02"); + } + + private static void ConfigureToday(IUserDateService userDateService, DateOnly today) => + userDateService.GetUserTodayAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(today); + + private static async Task SeedUserAsync( + OrbitDbContext dbContext, + bool withActivity, + bool withSubscription, + string name = "User", + string? timeZone = null) + { + var user = User.Create(name, $"{name.ToLowerInvariant()}-{Guid.NewGuid():N}@example.com").Value; + if (timeZone is not null) + user.SetTimeZone(timeZone).IsSuccess.Should().BeTrue(); + + var habit = Habit.Create(new HabitCreateParams( + user.Id, + $"{name} habit", + FrequencyUnit.Day, + 1, + DueDate: new DateOnly(2026, 1, 1))).Value; + if (withActivity) + habit.Log(ClosedMonthLogDate, advanceDueDate: false).IsSuccess.Should().BeTrue(); + + dbContext.Users.Add(user); + dbContext.Habits.Add(habit); + if (withSubscription) + { + dbContext.PushSubscriptions.Add(PushSubscription.Create( + user.Id, + $"token-{user.Id}", + PushSubscription.FcmSentinel, + "auth").Value); + } + + await dbContext.SaveChangesAsync(); + return user; + } + + private static OrbitDbContext CreateDbContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase($"PeriodCloseNotificationServiceTests_{Guid.NewGuid()}") + .Options); + + private static PeriodCloseNotificationService CreateService( + OrbitDbContext dbContext, + IPushNotificationService pushService, + IUserDateService userDateService) + { + var serviceProvider = new ServiceCollection() + .AddSingleton(dbContext) + .AddSingleton(pushService) + .AddSingleton(userDateService) + .BuildServiceProvider(); + return new PeriodCloseNotificationService( + serviceProvider.GetRequiredService(), + NullLogger.Instance, + new ConfigurationBuilder().Build()); + } +} From 522939e3eaa4b7052a82a5782e3795ef51aff834 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Tue, 25 Aug 2026 17:50:38 -0300 Subject: [PATCH 3/5] chore: refresh architecture map --- architecture.html | 2 +- architecture.json | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/architecture.html b/architecture.html index c4a7641f..cb657e40 100644 --- a/architecture.html +++ b/architecture.html @@ -47,7 +47,7 @@

Handlers with no endpoint

RequestHandler file

Entities

EntityDomain file
- +