From 79c16402ea07bad15931bf836a3a6b794227a1c8 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 12 Jul 2026 20:59:26 -0300 Subject: [PATCH 1/2] fix(api): derive AI-cache/prompt "today" from the user timezone, not UTC Behavior-preserving timezone-correctness cleanup against the frozen prod-readiness audit (Batch 5). - CacheInvalidationHelper.InvalidateSummaryCache/InvalidateRetrospectiveCache (and InvalidateUserAiCaches) now take an explicit `DateOnly today` instead of computing DateOnly.FromDateTime(DateTime.UtcNow); all 22 command callers pass await userDateService.GetUserTodayAsync(userId). The +/-2-day window still covers the user's cached key, so invalidation is behavior-preserving. - StreakGoalSyncService: drop the UTC-derived log-window cutoff; the per-user local today already drives the streak window inside HabitMetricsCalculator (bounded to MaxStreakLookbackDays), so the loaded-log filter is unnecessary and results are identical. Demote the idempotent sync-conflict log to Debug. - TodayDateSection/ImageInstructionsSection: remove the `?? UtcNow` fallback and throw InvalidOperationException when UserToday is null; the prompt-building path (ProcessUserChatCommand) always supplies the user's local today. HabitInvariants.ValidateDateOptions was already remediated by #330 (its `?? DateOnly.FromDateTime(DateTime.UtcNow)` fallback removed; dueDate is now caller-supplied), so it is intentionally untouched. Refs thomasluizon/orbit-ui-mobile#243 Co-Authored-By: Claude Opus 4.8 --- .../Common/CacheInvalidationHelper.cs | 14 +++----- .../Goals/Commands/CreateGoalCommand.cs | 11 +++--- .../Goals/Commands/DeleteGoalCommand.cs | 4 ++- .../Goals/Commands/LinkHabitsToGoalCommand.cs | 4 ++- .../Goals/Commands/ReorderGoalsCommand.cs | 4 ++- .../Goals/Commands/RestoreGoalCommand.cs | 4 ++- .../Goals/Commands/UpdateGoalCommand.cs | 11 +++--- .../Commands/UpdateGoalProgressCommand.cs | 4 ++- .../Goals/Commands/UpdateGoalStatusCommand.cs | 4 ++- .../Commands/BulkCreateHabitsCommand.cs | 2 +- .../Commands/BulkDeleteHabitsCommand.cs | 4 ++- .../Habits/Commands/BulkLogHabitsCommand.cs | 2 +- .../Habits/Commands/BulkSkipHabitsCommand.cs | 2 +- .../Habits/Commands/CreateHabitCommand.cs | 5 +-- .../Habits/Commands/CreateSubHabitCommand.cs | 2 +- .../Habits/Commands/DeleteHabitCommand.cs | 4 ++- .../Habits/Commands/DuplicateHabitCommand.cs | 4 ++- .../Habits/Commands/LogHabitCommand.cs | 10 +++--- .../Habits/Commands/RestoreHabitCommand.cs | 4 ++- .../Habits/Commands/SkipHabitCommand.cs | 4 +-- .../Habits/Commands/UpdateHabitCommand.cs | 3 +- .../Commands/ApplyOnboardingCommand.cs | 5 ++- .../Profile/Commands/ResetAccountCommand.cs | 4 ++- .../Dynamic/ImageInstructionsSection.cs | 4 ++- .../Sections/Dynamic/TodayDateSection.cs | 4 ++- .../Services/StreakGoalSyncService.cs | 7 ++-- .../Caching/GoalAiCacheInvalidationTests.cs | 2 +- .../Goals/DeleteGoalCommandHandlerTests.cs | 2 +- .../LinkHabitsToGoalCommandHandlerTests.cs | 2 +- .../Goals/ReorderGoalsCommandHandlerTests.cs | 2 +- .../Goals/RestoreGoalCommandHandlerTests.cs | 2 +- .../UpdateGoalProgressCommandHandlerTests.cs | 2 +- .../UpdateGoalStatusCommandHandlerTests.cs | 2 +- .../BulkDeleteHabitsCommandHandlerTests.cs | 2 +- .../Habits/DeleteHabitCommandHandlerTests.cs | 2 +- .../DuplicateHabitCommandHandlerTests.cs | 2 +- .../Habits/RestoreHabitCommandHandlerTests.cs | 2 +- .../Profile/ProfileCommandHandlerTests.cs | 4 +-- .../ResetAccountCommandHandlerTests.cs | 2 +- .../Common/CacheInvalidationHelperTests.cs | 34 ++++++++++++++++++- .../Persistence/ConcurrencyRetryTests.cs | 1 + .../Services/PromptSectionTests.cs | 19 ++++++++--- 42 files changed, 138 insertions(+), 74 deletions(-) diff --git a/src/Orbit.Application/Common/CacheInvalidationHelper.cs b/src/Orbit.Application/Common/CacheInvalidationHelper.cs index 6ef6753a..4fd50428 100644 --- a/src/Orbit.Application/Common/CacheInvalidationHelper.cs +++ b/src/Orbit.Application/Common/CacheInvalidationHelper.cs @@ -7,10 +7,8 @@ public static class CacheInvalidationHelper private static readonly string[] RetrospectivePeriods = ["week", "month", "quarter", "semester", "year"]; private static readonly string[] SummaryTimeBuckets = ["morning", "afternoon", "evening", "night", "timeless"]; - public static void InvalidateSummaryCache(IMemoryCache cache, Guid userId) + public static void InvalidateSummaryCache(IMemoryCache cache, Guid userId, DateOnly today) { - var nowAtUtc = DateTime.UtcNow; - var today = DateOnly.FromDateTime(nowAtUtc); for (int i = -2; i <= 2; i++) { var date = today.AddDays(i); @@ -28,10 +26,8 @@ public static void InvalidateSummaryCache(IMemoryCache cache, Guid userId) /// changes habits, logs, or goals, otherwise users see a stale 1-hour-old retrospective /// after logging or editing. /// - public static void InvalidateRetrospectiveCache(IMemoryCache cache, Guid userId) + public static void InvalidateRetrospectiveCache(IMemoryCache cache, Guid userId, DateOnly today) { - var nowAtUtc = DateTime.UtcNow; - var today = DateOnly.FromDateTime(nowAtUtc); for (int i = -2; i <= 2; i++) { var date = today.AddDays(i); @@ -56,10 +52,10 @@ public static void InvalidateGoalReviewCache(IMemoryCache cache, Guid userId) /// Convenience: invalidate the summary, retrospective, and goal-review caches for a user. Use /// this from any mutation command that affects habits, logs, or goals. /// - public static void InvalidateUserAiCaches(IMemoryCache cache, Guid userId) + public static void InvalidateUserAiCaches(IMemoryCache cache, Guid userId, DateOnly today) { - InvalidateSummaryCache(cache, userId); - InvalidateRetrospectiveCache(cache, userId); + InvalidateSummaryCache(cache, userId, today); + InvalidateRetrospectiveCache(cache, userId, today); InvalidateGoalReviewCache(cache, userId); } } diff --git a/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs b/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs index 14bfb2c3..6fc3f20b 100644 --- a/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs @@ -34,12 +34,9 @@ public async Task> Handle(CreateGoalCommand request, CancellationTo if (gateCheck.IsFailure) return gateCheck.PropagateError(); - if (request.Deadline is { } deadline) - { - var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); - if (deadline < today) - return Result.Failure(ErrorMessages.DeadlineInPast); - } + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + if (request.Deadline is { } deadline && deadline < today) + return Result.Failure(ErrorMessages.DeadlineInPast); var goalResult = Goal.Create(new Goal.CreateGoalParams( request.UserId, @@ -67,7 +64,7 @@ public async Task> Handle(CreateGoalCommand request, CancellationTo LogGamificationGoalCreationFailed(logger, ex, request.UserId); } - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(goal.Id); } diff --git a/src/Orbit.Application/Goals/Commands/DeleteGoalCommand.cs b/src/Orbit.Application/Goals/Commands/DeleteGoalCommand.cs index 07e2ac83..319e5acd 100644 --- a/src/Orbit.Application/Goals/Commands/DeleteGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/DeleteGoalCommand.cs @@ -16,6 +16,7 @@ public class DeleteGoalCommandHandler( IGenericRepository goalRepository, IPayGateService payGate, IUnitOfWork unitOfWork, + IUserDateService userDateService, IMemoryCache cache) : IRequestHandler { public async Task Handle(DeleteGoalCommand request, CancellationToken cancellationToken) @@ -34,7 +35,8 @@ public async Task Handle(DeleteGoalCommand request, CancellationToken ca goal.SoftDelete(); await unitOfWork.SaveChangesAsync(cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Application/Goals/Commands/LinkHabitsToGoalCommand.cs b/src/Orbit.Application/Goals/Commands/LinkHabitsToGoalCommand.cs index 71bb6583..d8e341de 100644 --- a/src/Orbit.Application/Goals/Commands/LinkHabitsToGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/LinkHabitsToGoalCommand.cs @@ -19,6 +19,7 @@ public class LinkHabitsToGoalCommandHandler( IGenericRepository habitRepository, IPayGateService payGate, IUnitOfWork unitOfWork, + IUserDateService userDateService, IMemoryCache cache) : IRequestHandler { public async Task Handle(LinkHabitsToGoalCommand request, CancellationToken cancellationToken) @@ -54,7 +55,8 @@ public async Task Handle(LinkHabitsToGoalCommand request, CancellationTo await unitOfWork.SaveChangesAsync(cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Application/Goals/Commands/ReorderGoalsCommand.cs b/src/Orbit.Application/Goals/Commands/ReorderGoalsCommand.cs index 418bc5f1..ac4ae0c5 100644 --- a/src/Orbit.Application/Goals/Commands/ReorderGoalsCommand.cs +++ b/src/Orbit.Application/Goals/Commands/ReorderGoalsCommand.cs @@ -18,6 +18,7 @@ public class ReorderGoalsCommandHandler( IGenericRepository goalRepository, IPayGateService payGate, IUnitOfWork unitOfWork, + IUserDateService userDateService, IMemoryCache cache) : IRequestHandler { public async Task Handle(ReorderGoalsCommand request, CancellationToken cancellationToken) @@ -50,7 +51,8 @@ public async Task Handle(ReorderGoalsCommand request, CancellationToken await unitOfWork.SaveChangesAsync(cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Application/Goals/Commands/RestoreGoalCommand.cs b/src/Orbit.Application/Goals/Commands/RestoreGoalCommand.cs index 18eeaccc..9db5e337 100644 --- a/src/Orbit.Application/Goals/Commands/RestoreGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/RestoreGoalCommand.cs @@ -16,6 +16,7 @@ public class RestoreGoalCommandHandler( IGenericRepository goalRepository, IPayGateService payGate, IUnitOfWork unitOfWork, + IUserDateService userDateService, IMemoryCache cache) : IRequestHandler { public async Task Handle(RestoreGoalCommand request, CancellationToken cancellationToken) @@ -35,7 +36,8 @@ public async Task Handle(RestoreGoalCommand request, CancellationToken c goal.Restore(); await unitOfWork.SaveChangesAsync(cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Application/Goals/Commands/UpdateGoalCommand.cs b/src/Orbit.Application/Goals/Commands/UpdateGoalCommand.cs index 1db30bcf..a05c4331 100644 --- a/src/Orbit.Application/Goals/Commands/UpdateGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/UpdateGoalCommand.cs @@ -35,12 +35,9 @@ public async Task Handle(UpdateGoalCommand request, CancellationToken ca if (gateCheck.IsFailure) return gateCheck; - if (request.Deadline is { } deadline) - { - var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); - if (deadline < today) - return Result.Failure(ErrorMessages.DeadlineInPast); - } + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + if (request.Deadline is { } deadline && deadline < today) + return Result.Failure(ErrorMessages.DeadlineInPast); var goal = await goalRepository.FindOneTrackedAsync( g => g.Id == request.GoalId && g.UserId == request.UserId, @@ -64,7 +61,7 @@ public async Task Handle(UpdateGoalCommand request, CancellationToken ca if (result.Value == GoalEditTransition.Completed) await ProcessGoalCompletionSafeAsync(request.UserId, cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs b/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs index 277ae268..f2e0946e 100644 --- a/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs +++ b/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs @@ -20,6 +20,7 @@ public partial class UpdateGoalProgressCommandHandler( IPayGateService payGate, IGamificationService gamificationService, IUnitOfWork unitOfWork, + IUserDateService userDateService, IMemoryCache cache, ILogger logger) : IRequestHandler { @@ -57,7 +58,8 @@ public async Task Handle(UpdateGoalProgressCommand request, Cancellation if (justCompleted) await ProcessGoalCompletionSafeAsync(request.UserId, cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Application/Goals/Commands/UpdateGoalStatusCommand.cs b/src/Orbit.Application/Goals/Commands/UpdateGoalStatusCommand.cs index b0e7a0bc..be1f2270 100644 --- a/src/Orbit.Application/Goals/Commands/UpdateGoalStatusCommand.cs +++ b/src/Orbit.Application/Goals/Commands/UpdateGoalStatusCommand.cs @@ -20,6 +20,7 @@ public partial class UpdateGoalStatusCommandHandler( IPayGateService payGate, IGamificationService gamificationService, IUnitOfWork unitOfWork, + IUserDateService userDateService, IMemoryCache cache, ILogger logger) : IRequestHandler { @@ -60,7 +61,8 @@ public async Task Handle(UpdateGoalStatusCommand request, CancellationTo } } - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs b/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs index 3ad4d205..88991e30 100644 --- a/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs +++ b/src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs @@ -106,7 +106,7 @@ await unitOfWork.ExecuteInTransactionAsync(async ct => } }, cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, userToday); return Result.Success(new BulkCreateResult(results)); } diff --git a/src/Orbit.Application/Habits/Commands/BulkDeleteHabitsCommand.cs b/src/Orbit.Application/Habits/Commands/BulkDeleteHabitsCommand.cs index 96a94312..be4ed981 100644 --- a/src/Orbit.Application/Habits/Commands/BulkDeleteHabitsCommand.cs +++ b/src/Orbit.Application/Habits/Commands/BulkDeleteHabitsCommand.cs @@ -23,6 +23,7 @@ public class BulkDeleteHabitsCommandHandler( IGenericRepository habitRepository, IUserStreakService userStreakService, IUnitOfWork unitOfWork, + IUserDateService userDateService, IMemoryCache cache) : IRequestHandler> { public async Task> Handle(BulkDeleteHabitsCommand request, CancellationToken cancellationToken) @@ -67,7 +68,8 @@ await ConcurrencyRetry.SaveWithRetryAsync( } }, cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(new BulkDeleteResult(results)); } diff --git a/src/Orbit.Application/Habits/Commands/BulkLogHabitsCommand.cs b/src/Orbit.Application/Habits/Commands/BulkLogHabitsCommand.cs index 4943c948..c0e1ccd9 100644 --- a/src/Orbit.Application/Habits/Commands/BulkLogHabitsCommand.cs +++ b/src/Orbit.Application/Habits/Commands/BulkLogHabitsCommand.cs @@ -99,7 +99,7 @@ await ConcurrencyRetry.SaveWithRetryAsync( } }, cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(new BulkLogResult(results)); } diff --git a/src/Orbit.Application/Habits/Commands/BulkSkipHabitsCommand.cs b/src/Orbit.Application/Habits/Commands/BulkSkipHabitsCommand.cs index 19363ffd..7f178417 100644 --- a/src/Orbit.Application/Habits/Commands/BulkSkipHabitsCommand.cs +++ b/src/Orbit.Application/Habits/Commands/BulkSkipHabitsCommand.cs @@ -70,7 +70,7 @@ await unitOfWork.ExecuteInTransactionAsync(async ct => await unitOfWork.SaveChangesAsync(ct); }, cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(new BulkSkipResult(results)); } diff --git a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs index 635dfb61..52bdb3c1 100644 --- a/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs @@ -70,7 +70,8 @@ public async Task> Handle(CreateHabitCommand request, CancellationT return slipAlertGate.PropagateError(); } - var dueDate = request.DueDate ?? await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + var dueDate = request.DueDate ?? today; if (opts.Days is { Count: > 0 } && !opts.Days.Contains(dueDate.DayOfWeek)) { @@ -125,7 +126,7 @@ public async Task> Handle(CreateHabitCommand request, CancellationT await ProcessGamificationSafeAsync(request.UserId, cancellationToken); await ProcessOnboardingChecklistSafeAsync(request.UserId, OnboardingChecklistSignal.HabitCreated, cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(habit.Id); } diff --git a/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs b/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs index d06bf940..67129b9a 100644 --- a/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/CreateSubHabitCommand.cs @@ -106,7 +106,7 @@ public async Task> Handle(CreateSubHabitCommand request, Cancellati await habitRepository.AddAsync(child, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, userToday); return Result.Success(childResult.Value.Id); } diff --git a/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs b/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs index b2fb0526..bb204df7 100644 --- a/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/DeleteHabitCommand.cs @@ -16,6 +16,7 @@ public class DeleteHabitCommandHandler( IGenericRepository habitRepository, IUserStreakService userStreakService, IUnitOfWork unitOfWork, + IUserDateService userDateService, IMemoryCache cache) : IRequestHandler { public async Task Handle(DeleteHabitCommand request, CancellationToken cancellationToken) @@ -43,7 +44,8 @@ await ConcurrencyRetry.SaveWithRetryAsync( ct => userStreakService.RecalculateAsync(request.UserId, ct), cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs index a62f4b86..86c7128a 100644 --- a/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs @@ -17,6 +17,7 @@ public class DuplicateHabitCommandHandler( IGenericRepository habitLogRepository, IPayGateService payGateService, IUnitOfWork unitOfWork, + IUserDateService userDateService, IMemoryCache cache) : IRequestHandler> { public async Task> Handle(DuplicateHabitCommand request, CancellationToken cancellationToken) @@ -61,7 +62,8 @@ public async Task> Handle(DuplicateHabitCommand request, Cancellati await unitOfWork.SaveChangesAsync(cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(rootCopy.Value.Id); } diff --git a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs index 382b327a..a49fc601 100644 --- a/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/LogHabitCommand.cs @@ -140,7 +140,7 @@ await ConcurrencyRetry.SaveWithRetryAsync( async ct => streakState = await services.UserStreakService.RecalculateAsync( habit.UserId, ct, awardFreezeIfEligible: false), cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, habit.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, habit.UserId, today); return Result.Success(new LogHabitResponse( unlogEntity.Id, @@ -179,7 +179,7 @@ private async Task> HandleLogAsync( } catch (DbUpdateException ex) when (IsUniqueViolation(ex)) { - return await BuildAlreadyLoggedResultAsync(habit, targetDate, cancellationToken); + return await BuildAlreadyLoggedResultAsync(habit, targetDate, today, cancellationToken); } catch (DbUpdateConcurrencyException) when (attempt < MaxLogAttempts) { @@ -202,7 +202,7 @@ private async Task> HandleLogAsync( if (gamificationResult is null) await PersistStreakRecalcAsync(request.UserId, cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, habit.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, habit.UserId, today); await CheckReferralCompletionSafeAsync(request.UserId, cancellationToken); @@ -242,7 +242,7 @@ private async Task PersistStreakRecalcAsync(Guid userId, CancellationToken cance } private async Task> BuildAlreadyLoggedResultAsync( - Habit habit, DateOnly targetDate, CancellationToken cancellationToken) + Habit habit, DateOnly targetDate, DateOnly today, CancellationToken cancellationToken) { var existingLogs = await repos.HabitLogRepository.FindAsync( l => l.HabitId == habit.Id && l.Date == targetDate && l.Value > 0, cancellationToken); @@ -251,7 +251,7 @@ private async Task> BuildAlreadyLoggedResultAsync( var users = await repos.UserRepository.FindAsync(u => u.Id == habit.UserId, cancellationToken); var currentStreak = users.SingleOrDefault()?.CurrentStreak ?? 0; - CacheInvalidationHelper.InvalidateUserAiCaches(cache, habit.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, habit.UserId, today); return Result.Success(new LogHabitResponse( winningLog.Id, diff --git a/src/Orbit.Application/Habits/Commands/RestoreHabitCommand.cs b/src/Orbit.Application/Habits/Commands/RestoreHabitCommand.cs index 0dbe77fe..8be0a2a7 100644 --- a/src/Orbit.Application/Habits/Commands/RestoreHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/RestoreHabitCommand.cs @@ -17,6 +17,7 @@ public class RestoreHabitCommandHandler( IGenericRepository habitRepository, IUserStreakService userStreakService, IUnitOfWork unitOfWork, + IUserDateService userDateService, IMemoryCache cache) : IRequestHandler { public async Task Handle(RestoreHabitCommand request, CancellationToken cancellationToken) @@ -42,7 +43,8 @@ await ConcurrencyRetry.SaveWithRetryAsync( ct => userStreakService.RecalculateAsync(request.UserId, ct), cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs b/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs index e1b57232..6ae46dec 100644 --- a/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/SkipHabitCommand.cs @@ -68,7 +68,7 @@ public async Task Handle(SkipHabitCommand request, CancellationToken can if (anyGoalJustCompleted) await ProcessGoalCompletionSafeAsync(habit.UserId, cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, habit.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, habit.UserId, today); return Result.Success(); } @@ -77,7 +77,7 @@ private async Task HandleOneTimeSkip(Habit habit, DateOnly today, Cancel { habit.PostponeTo(today.AddDays(1)); await unitOfWork.SaveChangesAsync(cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, habit.UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, habit.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs index a8c2039f..45859258 100644 --- a/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs +++ b/src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs @@ -99,7 +99,8 @@ public async Task Handle(UpdateHabitCommand request, CancellationToken c await unitOfWork.SaveChangesAsync(cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs b/src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs index db4ae6ae..39ff6cb1 100644 --- a/src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs +++ b/src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs @@ -122,7 +122,10 @@ public async Task> Handle( }, cancellationToken); if (result.IsSuccess && result.Value.Applied) - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + { + var userToday = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, userToday); + } return result; } diff --git a/src/Orbit.Application/Profile/Commands/ResetAccountCommand.cs b/src/Orbit.Application/Profile/Commands/ResetAccountCommand.cs index bdd6662d..266f454d 100644 --- a/src/Orbit.Application/Profile/Commands/ResetAccountCommand.cs +++ b/src/Orbit.Application/Profile/Commands/ResetAccountCommand.cs @@ -14,6 +14,7 @@ public class ResetAccountCommandHandler( IGenericRepository userRepository, IAccountResetRepository accountResetRepository, IUnitOfWork unitOfWork, + IUserDateService userDateService, IMemoryCache cache) : IRequestHandler { public async Task Handle(ResetAccountCommand request, CancellationToken cancellationToken) @@ -33,7 +34,8 @@ await unitOfWork.ExecuteInTransactionAsync(async ct => await unitOfWork.SaveChangesAsync(ct); }, cancellationToken); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + var today = await userDateService.GetUserTodayAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId, today); return Result.Success(); } diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/ImageInstructionsSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/ImageInstructionsSection.cs index 79852277..868da93b 100644 --- a/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/ImageInstructionsSection.cs +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/ImageInstructionsSection.cs @@ -9,7 +9,9 @@ public class ImageInstructionsSection : IPromptSection public string Build(PromptContext context) { - var today = (context.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)).ToString("yyyy-MM-dd"); + var today = (context.UserToday ?? throw new InvalidOperationException( + "PromptContext.UserToday must be set before building the image-instructions section.")) + .ToString("yyyy-MM-dd"); var sb = new StringBuilder(); sb.AppendLine($$""" ## Image Analysis Instructions diff --git a/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/TodayDateSection.cs b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/TodayDateSection.cs index a04ee801..e9e49544 100644 --- a/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/TodayDateSection.cs +++ b/src/Orbit.Infrastructure/Services/Prompts/Sections/Dynamic/TodayDateSection.cs @@ -9,9 +9,11 @@ public class TodayDateSection : IPromptSection public string Build(PromptContext context) { + var today = context.UserToday ?? throw new InvalidOperationException( + "PromptContext.UserToday must be set before building the today-date section."); var sb = new StringBuilder(); sb.AppendLine(); - sb.AppendLine($"## Today's Date: {(context.UserToday ?? DateOnly.FromDateTime(DateTime.UtcNow)):yyyy-MM-dd}"); + sb.AppendLine($"## Today's Date: {today:yyyy-MM-dd}"); sb.AppendLine(); return sb.ToString(); } diff --git a/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs b/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs index c3ac30ba..21e70802 100644 --- a/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs +++ b/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs @@ -73,10 +73,9 @@ internal async Task SyncActiveStreakGoals(CancellationToken ct) using var scope = scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - var streakWindowStart = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-AppConstants.MaxStreakLookbackDays - 1); var goals = await dbContext.Goals .Where(g => g.Type == GoalType.Streak && g.Status == GoalStatus.Active && !g.IsDeleted) - .Include(g => g.Habits).ThenInclude(h => h.Logs.Where(l => l.Date >= streakWindowStart)) + .Include(g => g.Habits).ThenInclude(h => h.Logs) .ToListAsync(ct); if (goals.Count == 0) return; @@ -122,7 +121,7 @@ private async Task TrySaveGoalAsync(Goal goal, OrbitDbContext dbContext, C catch (Exception ex) when (ex is DbUpdateConcurrencyException || DbUniqueViolation.IsUniqueViolation(ex)) { await dbContext.Entry(goal).ReloadAsync(ct); - if (logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Debug)) LogStreakGoalSyncConflict(logger, goal.Id); return false; } @@ -160,6 +159,6 @@ private async Task ProcessCompletedGoalsAsync( [LoggerMessage(EventId = 5, Level = LogLevel.Warning, Message = "Gamification processing failed for streak goal completion by user {UserId}")] private static partial void LogGamificationGoalCompletionFailed(ILogger logger, Exception ex, Guid userId); - [LoggerMessage(EventId = 6, Level = LogLevel.Information, Message = "Streak goal {GoalId} sync raced a concurrent writer; skipping (already synced)")] + [LoggerMessage(EventId = 6, Level = LogLevel.Debug, Message = "Streak goal {GoalId} sync raced a concurrent writer; skipping (already synced)")] private static partial void LogStreakGoalSyncConflict(ILogger logger, Guid goalId); } diff --git a/tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs b/tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs index 472334f2..c26d4187 100644 --- a/tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs +++ b/tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs @@ -35,7 +35,7 @@ public void InvalidateUserAiCaches_AlsoClearsGoalReview() var cache = new MemoryCache(new MemoryCacheOptions()); cache.Set(GoalReviewKey("en"), "cached review"); - CacheInvalidationHelper.InvalidateUserAiCaches(cache, UserId); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, UserId, new DateOnly(2026, 7, 12)); cache.TryGetValue(GoalReviewKey("en"), out _).Should().BeFalse(); } diff --git a/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs index 0f4743c7..e5faf51d 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs @@ -23,7 +23,7 @@ public class DeleteGoalCommandHandlerTests public DeleteGoalCommandHandlerTests() { - _handler = new DeleteGoalCommandHandler(_goalRepo, _payGate, _unitOfWork, _cache); + _handler = new DeleteGoalCommandHandler(_goalRepo, _payGate, _unitOfWork, Substitute.For(), _cache); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); } diff --git a/tests/Orbit.Application.Tests/Commands/Goals/LinkHabitsToGoalCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/LinkHabitsToGoalCommandHandlerTests.cs index ce767416..797ffdfb 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/LinkHabitsToGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/LinkHabitsToGoalCommandHandlerTests.cs @@ -25,7 +25,7 @@ public class LinkHabitsToGoalCommandHandlerTests public LinkHabitsToGoalCommandHandlerTests() { - _handler = new LinkHabitsToGoalCommandHandler(_goalRepo, _habitRepo, _payGate, _unitOfWork, _cache); + _handler = new LinkHabitsToGoalCommandHandler(_goalRepo, _habitRepo, _payGate, _unitOfWork, Substitute.For(), _cache); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); } diff --git a/tests/Orbit.Application.Tests/Commands/Goals/ReorderGoalsCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/ReorderGoalsCommandHandlerTests.cs index 6202396f..f1089ebe 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/ReorderGoalsCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/ReorderGoalsCommandHandlerTests.cs @@ -22,7 +22,7 @@ public class ReorderGoalsCommandHandlerTests public ReorderGoalsCommandHandlerTests() { - _handler = new ReorderGoalsCommandHandler(_goalRepo, _payGate, _unitOfWork, _cache); + _handler = new ReorderGoalsCommandHandler(_goalRepo, _payGate, _unitOfWork, Substitute.For(), _cache); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); } diff --git a/tests/Orbit.Application.Tests/Commands/Goals/RestoreGoalCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/RestoreGoalCommandHandlerTests.cs index 7be78dcc..615d40bd 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/RestoreGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/RestoreGoalCommandHandlerTests.cs @@ -23,7 +23,7 @@ public class RestoreGoalCommandHandlerTests public RestoreGoalCommandHandlerTests() { - _handler = new RestoreGoalCommandHandler(_goalRepo, _payGate, _unitOfWork, _cache); + _handler = new RestoreGoalCommandHandler(_goalRepo, _payGate, _unitOfWork, Substitute.For(), _cache); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); } diff --git a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalProgressCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalProgressCommandHandlerTests.cs index 55478b27..446f4e11 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalProgressCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalProgressCommandHandlerTests.cs @@ -27,7 +27,7 @@ public class UpdateGoalProgressCommandHandlerTests public UpdateGoalProgressCommandHandlerTests() { _handler = new UpdateGoalProgressCommandHandler( - _goalRepo, _progressLogRepo, _payGate, _gamificationService, _unitOfWork, _cache, + _goalRepo, _progressLogRepo, _payGate, _gamificationService, _unitOfWork, Substitute.For(), _cache, Substitute.For>()); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); diff --git a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalStatusCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalStatusCommandHandlerTests.cs index 7929d8e5..3d9b2918 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalStatusCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalStatusCommandHandlerTests.cs @@ -28,7 +28,7 @@ public class UpdateGoalStatusCommandHandlerTests public UpdateGoalStatusCommandHandlerTests() { _handler = new UpdateGoalStatusCommandHandler( - _goalRepo, _payGate, _gamificationService, _unitOfWork, _cache, + _goalRepo, _payGate, _gamificationService, _unitOfWork, Substitute.For(), _cache, Substitute.For>()); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); diff --git a/tests/Orbit.Application.Tests/Commands/Habits/BulkDeleteHabitsCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/BulkDeleteHabitsCommandHandlerTests.cs index 6ef1c6f1..026d0ff8 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/BulkDeleteHabitsCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/BulkDeleteHabitsCommandHandlerTests.cs @@ -23,7 +23,7 @@ public class BulkDeleteHabitsCommandHandlerTests public BulkDeleteHabitsCommandHandlerTests() { - _handler = new BulkDeleteHabitsCommandHandler(_habitRepo, _userStreakService, _unitOfWork, _cache); + _handler = new BulkDeleteHabitsCommandHandler(_habitRepo, _userStreakService, _unitOfWork, Substitute.For(), _cache); _userStreakService.RecalculateAsync(UserId, Arg.Any()) .Returns(new UserStreakState(0, 0, null)); _unitOfWork.ExecuteInTransactionAsync( diff --git a/tests/Orbit.Application.Tests/Commands/Habits/DeleteHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/DeleteHabitCommandHandlerTests.cs index 75af9ca7..bb4ccf37 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/DeleteHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/DeleteHabitCommandHandlerTests.cs @@ -23,7 +23,7 @@ public class DeleteHabitCommandHandlerTests public DeleteHabitCommandHandlerTests() { - _handler = new DeleteHabitCommandHandler(_habitRepo, _userStreakService, _unitOfWork, _cache); + _handler = new DeleteHabitCommandHandler(_habitRepo, _userStreakService, _unitOfWork, Substitute.For(), _cache); _userStreakService.RecalculateAsync(UserId, Arg.Any()) .Returns(new UserStreakState(0, 0, null)); } diff --git a/tests/Orbit.Application.Tests/Commands/Habits/DuplicateHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/DuplicateHabitCommandHandlerTests.cs index ecd6cc30..79d4c912 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/DuplicateHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/DuplicateHabitCommandHandlerTests.cs @@ -24,7 +24,7 @@ public class DuplicateHabitCommandHandlerTests public DuplicateHabitCommandHandlerTests() { - _handler = new DuplicateHabitCommandHandler(_habitRepo, _habitLogRepo, _payGate, _unitOfWork, _cache); + _handler = new DuplicateHabitCommandHandler(_habitRepo, _habitLogRepo, _payGate, _unitOfWork, Substitute.For(), _cache); _payGate.CanCreateHabits(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Success()); diff --git a/tests/Orbit.Application.Tests/Commands/Habits/RestoreHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/RestoreHabitCommandHandlerTests.cs index 4e320472..0e2bc7d0 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/RestoreHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/RestoreHabitCommandHandlerTests.cs @@ -23,7 +23,7 @@ public class RestoreHabitCommandHandlerTests public RestoreHabitCommandHandlerTests() { - _handler = new RestoreHabitCommandHandler(_habitRepo, _userStreakService, _unitOfWork, _cache); + _handler = new RestoreHabitCommandHandler(_habitRepo, _userStreakService, _unitOfWork, Substitute.For(), _cache); _userStreakService.RecalculateAsync(UserId, Arg.Any()) .Returns(new UserStreakState(0, 0, null)); } diff --git a/tests/Orbit.Application.Tests/Commands/Profile/ProfileCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Profile/ProfileCommandHandlerTests.cs index 3a216522..6d10a2ee 100644 --- a/tests/Orbit.Application.Tests/Commands/Profile/ProfileCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Profile/ProfileCommandHandlerTests.cs @@ -319,7 +319,7 @@ public async Task ResetAccount_Valid_ResetsAndSaves() SetupUserFound(user); var accountResetRepo = Substitute.For(); - var handler = new ResetAccountCommandHandler(_userRepo, accountResetRepo, _unitOfWork, _cache); + var handler = new ResetAccountCommandHandler(_userRepo, accountResetRepo, _unitOfWork, Substitute.For(), _cache); var command = new ResetAccountCommand(UserId); var result = await handler.Handle(command, CancellationToken.None); @@ -338,7 +338,7 @@ public async Task ResetAccount_UserNotFound_ReturnsFailure() SetupUserNotFound(); var accountResetRepo = Substitute.For(); - var handler = new ResetAccountCommandHandler(_userRepo, accountResetRepo, _unitOfWork, _cache); + var handler = new ResetAccountCommandHandler(_userRepo, accountResetRepo, _unitOfWork, Substitute.For(), _cache); var command = new ResetAccountCommand(UserId); var result = await handler.Handle(command, CancellationToken.None); diff --git a/tests/Orbit.Application.Tests/Commands/Profile/ResetAccountCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Profile/ResetAccountCommandHandlerTests.cs index 1ad77d78..52360a28 100644 --- a/tests/Orbit.Application.Tests/Commands/Profile/ResetAccountCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Profile/ResetAccountCommandHandlerTests.cs @@ -20,7 +20,7 @@ public class ResetAccountCommandHandlerTests public ResetAccountCommandHandlerTests() { - _handler = new ResetAccountCommandHandler(_userRepo, _accountResetRepo, _unitOfWork, _cache); + _handler = new ResetAccountCommandHandler(_userRepo, _accountResetRepo, _unitOfWork, Substitute.For(), _cache); _unitOfWork.ExecuteInTransactionAsync( Arg.Any>(), Arg.Any()) diff --git a/tests/Orbit.Application.Tests/Common/CacheInvalidationHelperTests.cs b/tests/Orbit.Application.Tests/Common/CacheInvalidationHelperTests.cs index 1fbe543e..9bf0436d 100644 --- a/tests/Orbit.Application.Tests/Common/CacheInvalidationHelperTests.cs +++ b/tests/Orbit.Application.Tests/Common/CacheInvalidationHelperTests.cs @@ -36,11 +36,43 @@ public void InvalidateSummaryCache_RemovesSummaryKeys() cache.TryGetValue(key, out _).Should().BeTrue($"key '{key}' should exist before invalidation"); } - CacheInvalidationHelper.InvalidateSummaryCache(cache, userId); + CacheInvalidationHelper.InvalidateSummaryCache(cache, userId, today); foreach (var key in keys) { cache.TryGetValue(key, out _).Should().BeFalse($"key '{key}' should be removed after invalidation"); } } + + [Fact] + public void InvalidateSummaryCache_UsesSuppliedTodayNotUtc() + { + var cache = new MemoryCache(new MemoryCacheOptions()); + var userId = Guid.NewGuid(); + var suppliedToday = new DateOnly(2020, 1, 15); + var utcToday = DateOnly.FromDateTime(DateTime.UtcNow); + var suppliedKey = $"summary:{userId}:{suppliedToday:yyyy-MM-dd}:en"; + var utcKey = $"summary:{userId}:{utcToday:yyyy-MM-dd}:en"; + cache.Set(suppliedKey, "cached"); + cache.Set(utcKey, "cached"); + + CacheInvalidationHelper.InvalidateSummaryCache(cache, userId, suppliedToday); + + cache.TryGetValue(suppliedKey, out _).Should().BeFalse("the supplied user-local today defines the window"); + cache.TryGetValue(utcKey, out _).Should().BeTrue("an unrelated UTC-dated key stays untouched"); + } + + [Fact] + public void InvalidateRetrospectiveCache_RemovesKeysAroundSuppliedToday() + { + var cache = new MemoryCache(new MemoryCacheOptions()); + var userId = Guid.NewGuid(); + var today = new DateOnly(2020, 1, 15); + var key = $"retro:{userId}:week:{today}:en"; + cache.Set(key, "cached"); + + CacheInvalidationHelper.InvalidateRetrospectiveCache(cache, userId, today); + + cache.TryGetValue(key, out _).Should().BeFalse(); + } } diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs index db37c458..fb53e500 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs @@ -277,6 +277,7 @@ private static UpdateGoalProgressCommandHandler CreateGoalProgressHandler(OrbitD PassingGoalGate(), Substitute.For(), new UnitOfWork(context, new DatabaseConnectionSettings()), + Substitute.For(), new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); diff --git a/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs b/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs index 874aef83..d78d7af8 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/PromptSectionTests.cs @@ -258,13 +258,13 @@ public void Build_WithUserToday_UsesProvidedDate() } [Fact] - public void Build_WithoutUserToday_UsesUtcNow() + public void Build_WithoutUserToday_Throws() { var ctx = new PromptContext(new List(), new List(), false, null, null, null, null); - var result = new TodayDateSection().Build(ctx); - var utcToday = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); - result.Should().Contain(utcToday); + var act = () => new TodayDateSection().Build(ctx); + + act.Should().Throw(); } } @@ -330,5 +330,16 @@ public void Build_ContainsImageAnalysisInstructions() result.Should().Contain("Image Analysis Instructions"); result.Should().Contain("Extract EVERYTHING visible"); + result.Should().Contain("2026-04-10"); + } + + [Fact] + public void Build_WithoutUserToday_Throws() + { + var ctx = new PromptContext(new List(), new List(), true, null, null, null, null); + + var act = () => new ImageInstructionsSection().Build(ctx); + + act.Should().Throw(); } } From 3af04568fe4c6f9b2576b113a312a77a1c0d5219 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 12 Jul 2026 23:03:07 -0300 Subject: [PATCH 2/2] fix(api): align handler cache-invalidation tests with user-timezone cache keys The timezone correctness change derives the summary cache key from the user's "today" (IUserDateService) instead of UTC. Updates the command-handler tests that seeded/asserted the old UTC-derived key so they build the expected key from the same mocked user-today the handler uses, and configures the IUserDateService substitutes the handlers now depend on (an unconfigured substitute returned DateOnly.MinValue, crashing CacheInvalidationHelper's AddDays). Also aligns the Infrastructure prompt-builder and concurrency-retry tests with the now-required PromptContext.UserToday. Fixes the Handle_InvalidatesSummaryCache-class failures without weakening the production change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Commands/Goals/DeleteGoalCommandHandlerTests.cs | 5 ++++- .../Commands/Goals/LinkHabitsToGoalCommandHandlerTests.cs | 5 ++++- .../Commands/Goals/ReorderGoalsCommandHandlerTests.cs | 5 ++++- .../Commands/Goals/RestoreGoalCommandHandlerTests.cs | 5 ++++- .../Goals/UpdateGoalProgressCommandHandlerTests.cs | 5 ++++- .../Commands/Goals/UpdateGoalStatusCommandHandlerTests.cs | 5 ++++- .../Habits/BulkCreateHabitsCommandHandlerTests.cs | 3 +-- .../Habits/BulkDeleteHabitsCommandHandlerTests.cs | 8 +++++--- .../Commands/Habits/BulkLogHabitsCommandHandlerTests.cs | 3 +-- .../Commands/Habits/BulkSkipHabitsCommandHandlerTests.cs | 3 +-- .../Commands/Habits/CreateHabitCommandHandlerTests.cs | 3 +-- .../Commands/Habits/DeleteHabitCommandHandlerTests.cs | 4 +++- .../Commands/Habits/DuplicateHabitCommandHandlerTests.cs | 8 +++++--- .../Commands/Habits/LogHabitCommandHandlerTests.cs | 3 +-- .../Commands/Habits/RestoreHabitCommandHandlerTests.cs | 4 +++- .../Commands/Habits/UpdateHabitCommandHandlerTests.cs | 3 +-- .../Profile/ApplyOnboardingCommandHandlerTests.cs | 2 +- .../Commands/Profile/ProfileCommandHandlerTests.cs | 6 ++++-- .../Commands/Profile/ResetAccountCommandHandlerTests.cs | 5 ++++- .../Persistence/ConcurrencyRetryTests.cs | 2 +- .../Services/SystemPromptBuilderTests.cs | 4 ++-- 21 files changed, 58 insertions(+), 33 deletions(-) diff --git a/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs index e5faf51d..56925a9b 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs @@ -16,14 +16,17 @@ public class DeleteGoalCommandHandlerTests private readonly IPayGateService _payGate = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly DeleteGoalCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); private static readonly Guid GoalId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 3, 20); public DeleteGoalCommandHandlerTests() { - _handler = new DeleteGoalCommandHandler(_goalRepo, _payGate, _unitOfWork, Substitute.For(), _cache); + _handler = new DeleteGoalCommandHandler(_goalRepo, _payGate, _unitOfWork, _userDateService, _cache); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); } diff --git a/tests/Orbit.Application.Tests/Commands/Goals/LinkHabitsToGoalCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/LinkHabitsToGoalCommandHandlerTests.cs index 797ffdfb..c757a3b0 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/LinkHabitsToGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/LinkHabitsToGoalCommandHandlerTests.cs @@ -18,14 +18,17 @@ public class LinkHabitsToGoalCommandHandlerTests private readonly IPayGateService _payGate = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly LinkHabitsToGoalCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); private static readonly Guid GoalId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 3, 20); public LinkHabitsToGoalCommandHandlerTests() { - _handler = new LinkHabitsToGoalCommandHandler(_goalRepo, _habitRepo, _payGate, _unitOfWork, Substitute.For(), _cache); + _handler = new LinkHabitsToGoalCommandHandler(_goalRepo, _habitRepo, _payGate, _unitOfWork, _userDateService, _cache); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); } diff --git a/tests/Orbit.Application.Tests/Commands/Goals/ReorderGoalsCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/ReorderGoalsCommandHandlerTests.cs index f1089ebe..e90773a9 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/ReorderGoalsCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/ReorderGoalsCommandHandlerTests.cs @@ -16,13 +16,16 @@ public class ReorderGoalsCommandHandlerTests private readonly IPayGateService _payGate = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly ReorderGoalsCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 3, 20); public ReorderGoalsCommandHandlerTests() { - _handler = new ReorderGoalsCommandHandler(_goalRepo, _payGate, _unitOfWork, Substitute.For(), _cache); + _handler = new ReorderGoalsCommandHandler(_goalRepo, _payGate, _unitOfWork, _userDateService, _cache); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); } diff --git a/tests/Orbit.Application.Tests/Commands/Goals/RestoreGoalCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/RestoreGoalCommandHandlerTests.cs index 615d40bd..2a6ad062 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/RestoreGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/RestoreGoalCommandHandlerTests.cs @@ -16,14 +16,17 @@ public class RestoreGoalCommandHandlerTests private readonly IPayGateService _payGate = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly RestoreGoalCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); private static readonly Guid GoalId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 3, 20); public RestoreGoalCommandHandlerTests() { - _handler = new RestoreGoalCommandHandler(_goalRepo, _payGate, _unitOfWork, Substitute.For(), _cache); + _handler = new RestoreGoalCommandHandler(_goalRepo, _payGate, _unitOfWork, _userDateService, _cache); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); } diff --git a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalProgressCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalProgressCommandHandlerTests.cs index 446f4e11..4b260904 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalProgressCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalProgressCommandHandlerTests.cs @@ -19,16 +19,19 @@ public class UpdateGoalProgressCommandHandlerTests private readonly IGamificationService _gamificationService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly UpdateGoalProgressCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); private static readonly Guid GoalId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 3, 20); public UpdateGoalProgressCommandHandlerTests() { _handler = new UpdateGoalProgressCommandHandler( - _goalRepo, _progressLogRepo, _payGate, _gamificationService, _unitOfWork, Substitute.For(), _cache, + _goalRepo, _progressLogRepo, _payGate, _gamificationService, _unitOfWork, _userDateService, _cache, Substitute.For>()); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); } diff --git a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalStatusCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalStatusCommandHandlerTests.cs index 3d9b2918..0a941c45 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalStatusCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalStatusCommandHandlerTests.cs @@ -20,16 +20,19 @@ public class UpdateGoalStatusCommandHandlerTests private readonly IGamificationService _gamificationService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly UpdateGoalStatusCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); private static readonly Guid GoalId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 3, 20); public UpdateGoalStatusCommandHandlerTests() { _handler = new UpdateGoalStatusCommandHandler( - _goalRepo, _payGate, _gamificationService, _unitOfWork, Substitute.For(), _cache, + _goalRepo, _payGate, _gamificationService, _unitOfWork, _userDateService, _cache, Substitute.For>()); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); } diff --git a/tests/Orbit.Application.Tests/Commands/Habits/BulkCreateHabitsCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/BulkCreateHabitsCommandHandlerTests.cs index 1d412626..64f294e5 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/BulkCreateHabitsCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/BulkCreateHabitsCommandHandlerTests.cs @@ -210,8 +210,7 @@ public async Task Handle_FromSyncReview_MarksMatchingSuggestionsImported() [Fact] public async Task Handle_InvalidatesSummaryCache() { - var realToday = DateOnly.FromDateTime(DateTime.UtcNow); - var cacheKey = $"summary:{UserId}:{realToday:yyyy-MM-dd}:en"; + var cacheKey = $"summary:{UserId}:{Today:yyyy-MM-dd}:en"; _cache.Set(cacheKey, "cached-summary"); var items = new List { new("Habit", null, FrequencyUnit.Day, 1) }; diff --git a/tests/Orbit.Application.Tests/Commands/Habits/BulkDeleteHabitsCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/BulkDeleteHabitsCommandHandlerTests.cs index 026d0ff8..a425932a 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/BulkDeleteHabitsCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/BulkDeleteHabitsCommandHandlerTests.cs @@ -17,13 +17,16 @@ public class BulkDeleteHabitsCommandHandlerTests private readonly IUserStreakService _userStreakService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly MemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly BulkDeleteHabitsCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 3, 20); public BulkDeleteHabitsCommandHandlerTests() { - _handler = new BulkDeleteHabitsCommandHandler(_habitRepo, _userStreakService, _unitOfWork, Substitute.For(), _cache); + _handler = new BulkDeleteHabitsCommandHandler(_habitRepo, _userStreakService, _unitOfWork, _userDateService, _cache); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _userStreakService.RecalculateAsync(UserId, Arg.Any()) .Returns(new UserStreakState(0, 0, null)); _unitOfWork.ExecuteInTransactionAsync( @@ -111,8 +114,7 @@ public async Task Handle_InvalidatesSummaryCache() Arg.Any()) .Returns(new List { habit }); - var realToday = DateOnly.FromDateTime(DateTime.UtcNow); - var cacheKey = $"summary:{UserId}:{realToday:yyyy-MM-dd}:en"; + var cacheKey = $"summary:{UserId}:{Today:yyyy-MM-dd}:en"; _cache.Set(cacheKey, "cached-summary"); var command = new BulkDeleteHabitsCommand(UserId, new List { habit.Id }); diff --git a/tests/Orbit.Application.Tests/Commands/Habits/BulkLogHabitsCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/BulkLogHabitsCommandHandlerTests.cs index 435e09c9..6147f104 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/BulkLogHabitsCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/BulkLogHabitsCommandHandlerTests.cs @@ -270,8 +270,7 @@ public async Task Handle_InvalidatesSummaryCache() var habit = Habit.Create(new HabitCreateParams(UserId, "Habit", FrequencyUnit.Day, 1, DueDate: Today)).Value; SetupHabitsForUser(new List { habit }); - var realToday = DateOnly.FromDateTime(DateTime.UtcNow); - var cacheKey = $"summary:{UserId}:{realToday:yyyy-MM-dd}:en"; + var cacheKey = $"summary:{UserId}:{Today:yyyy-MM-dd}:en"; _cache.Set(cacheKey, "cached-summary"); var items = new List { new(habit.Id) }; diff --git a/tests/Orbit.Application.Tests/Commands/Habits/BulkSkipHabitsCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/BulkSkipHabitsCommandHandlerTests.cs index 776b34bc..613d6578 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/BulkSkipHabitsCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/BulkSkipHabitsCommandHandlerTests.cs @@ -172,8 +172,7 @@ public async Task Handle_InvalidatesSummaryCache() var habit = Habit.Create(new HabitCreateParams(UserId, "Task", null, null, DueDate: Today)).Value; SetupHabitsForUser(new List { habit }); - var realToday = DateOnly.FromDateTime(DateTime.UtcNow); - var cacheKey = $"summary:{UserId}:{realToday:yyyy-MM-dd}:en"; + var cacheKey = $"summary:{UserId}:{Today:yyyy-MM-dd}:en"; _cache.Set(cacheKey, "cached-summary"); var items = new List { new(habit.Id) }; diff --git a/tests/Orbit.Application.Tests/Commands/Habits/CreateHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/CreateHabitCommandHandlerTests.cs index c8c31e7d..db67100c 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/CreateHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/CreateHabitCommandHandlerTests.cs @@ -204,8 +204,7 @@ public async Task Handle_InvalidTitle_ReturnsFailure() [Fact] public async Task Handle_InvalidatesSummaryCache() { - var realToday = DateOnly.FromDateTime(DateTime.UtcNow); - var cacheKey = $"summary:{UserId}:{realToday:yyyy-MM-dd}:en"; + var cacheKey = $"summary:{UserId}:{Today:yyyy-MM-dd}:en"; _cache.Set(cacheKey, "cached-summary"); var command = new CreateHabitCommand(UserId, "Test habit", null, FrequencyUnit.Day, 1); diff --git a/tests/Orbit.Application.Tests/Commands/Habits/DeleteHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/DeleteHabitCommandHandlerTests.cs index bb4ccf37..e1b20d3d 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/DeleteHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/DeleteHabitCommandHandlerTests.cs @@ -16,6 +16,7 @@ public class DeleteHabitCommandHandlerTests private readonly IUserStreakService _userStreakService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly DeleteHabitCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -23,7 +24,8 @@ public class DeleteHabitCommandHandlerTests public DeleteHabitCommandHandlerTests() { - _handler = new DeleteHabitCommandHandler(_habitRepo, _userStreakService, _unitOfWork, Substitute.For(), _cache); + _handler = new DeleteHabitCommandHandler(_habitRepo, _userStreakService, _unitOfWork, _userDateService, _cache); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _userStreakService.RecalculateAsync(UserId, Arg.Any()) .Returns(new UserStreakState(0, 0, null)); } diff --git a/tests/Orbit.Application.Tests/Commands/Habits/DuplicateHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/DuplicateHabitCommandHandlerTests.cs index 79d4c912..a6481001 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/DuplicateHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/DuplicateHabitCommandHandlerTests.cs @@ -18,13 +18,16 @@ public class DuplicateHabitCommandHandlerTests private readonly IPayGateService _payGate = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly MemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly DuplicateHabitCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 3, 20); public DuplicateHabitCommandHandlerTests() { - _handler = new DuplicateHabitCommandHandler(_habitRepo, _habitLogRepo, _payGate, _unitOfWork, Substitute.For(), _cache); + _handler = new DuplicateHabitCommandHandler(_habitRepo, _habitLogRepo, _payGate, _unitOfWork, _userDateService, _cache); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _payGate.CanCreateHabits(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Success()); @@ -162,8 +165,7 @@ public async Task Handle_InvalidatesSummaryCache() var original = Habit.Create(new HabitCreateParams(UserId, "Habit", FrequencyUnit.Day, 1, DueDate: DateOnly.FromDateTime(DateTime.UtcNow))).Value; SetupAllHabitsForUser(new List { original }); - var realToday = DateOnly.FromDateTime(DateTime.UtcNow); - var cacheKey = $"summary:{UserId}:{realToday:yyyy-MM-dd}:en"; + var cacheKey = $"summary:{UserId}:{Today:yyyy-MM-dd}:en"; _cache.Set(cacheKey, "cached-summary"); var command = new DuplicateHabitCommand(UserId, original.Id); diff --git a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs index 1e192527..0fda2af9 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/LogHabitCommandHandlerTests.cs @@ -200,8 +200,7 @@ public async Task Handle_InvalidatesSummaryCache() Arg.Any()) .Returns(habit); - var realToday = DateOnly.FromDateTime(DateTime.UtcNow); - var cacheKey = $"summary:{UserId}:{realToday:yyyy-MM-dd}:en"; + var cacheKey = $"summary:{UserId}:{Today:yyyy-MM-dd}:en"; _cache.Set(cacheKey, "cached-summary"); var command = new LogHabitCommand(UserId, habit.Id); diff --git a/tests/Orbit.Application.Tests/Commands/Habits/RestoreHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/RestoreHabitCommandHandlerTests.cs index 0e2bc7d0..f16bafc7 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/RestoreHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/RestoreHabitCommandHandlerTests.cs @@ -16,6 +16,7 @@ public class RestoreHabitCommandHandlerTests private readonly IUserStreakService _userStreakService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly RestoreHabitCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -23,7 +24,8 @@ public class RestoreHabitCommandHandlerTests public RestoreHabitCommandHandlerTests() { - _handler = new RestoreHabitCommandHandler(_habitRepo, _userStreakService, _unitOfWork, Substitute.For(), _cache); + _handler = new RestoreHabitCommandHandler(_habitRepo, _userStreakService, _unitOfWork, _userDateService, _cache); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _userStreakService.RecalculateAsync(UserId, Arg.Any()) .Returns(new UserStreakState(0, 0, null)); } diff --git a/tests/Orbit.Application.Tests/Commands/Habits/UpdateHabitCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Habits/UpdateHabitCommandHandlerTests.cs index c7eca5de..4b9899ab 100644 --- a/tests/Orbit.Application.Tests/Commands/Habits/UpdateHabitCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Habits/UpdateHabitCommandHandlerTests.cs @@ -159,8 +159,7 @@ public async Task Handle_InvalidatesSummaryCache() Arg.Any()) .Returns(habit); - var realToday = DateOnly.FromDateTime(DateTime.UtcNow); - var cacheKey = $"summary:{UserId}:{realToday:yyyy-MM-dd}:en"; + var cacheKey = $"summary:{UserId}:{Today:yyyy-MM-dd}:en"; _cache.Set(cacheKey, "cached-summary"); var command = new UpdateHabitCommand( diff --git a/tests/Orbit.Application.Tests/Commands/Profile/ApplyOnboardingCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Profile/ApplyOnboardingCommandHandlerTests.cs index 9c64afeb..e08e30e9 100644 --- a/tests/Orbit.Application.Tests/Commands/Profile/ApplyOnboardingCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Profile/ApplyOnboardingCommandHandlerTests.cs @@ -71,7 +71,7 @@ private static ApplyHabitInput Habit(string title) => new(title, null, null, FrequencyUnit.Day, 1); private static string SummaryCacheKey() => - $"summary:{UserId}:{DateOnly.FromDateTime(DateTime.UtcNow):yyyy-MM-dd}:en"; + $"summary:{UserId}:{Today:yyyy-MM-dd}:en"; [Fact] public async Task Apply_HappyPath_CreatesEverythingAndCompletesOnboarding() diff --git a/tests/Orbit.Application.Tests/Commands/Profile/ProfileCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Profile/ProfileCommandHandlerTests.cs index 6d10a2ee..f44220ab 100644 --- a/tests/Orbit.Application.Tests/Commands/Profile/ProfileCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Profile/ProfileCommandHandlerTests.cs @@ -18,6 +18,7 @@ public class ProfileCommandHandlerTests private readonly IUserDateService _userDateService = Substitute.For(); private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 3, 20); private static User CreateTestUser() { @@ -26,6 +27,7 @@ private static User CreateTestUser() public ProfileCommandHandlerTests() { + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _payGate.CanManageAiMemory(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success())); _payGate.CanManageAiSummary(Arg.Any(), Arg.Any()) @@ -319,7 +321,7 @@ public async Task ResetAccount_Valid_ResetsAndSaves() SetupUserFound(user); var accountResetRepo = Substitute.For(); - var handler = new ResetAccountCommandHandler(_userRepo, accountResetRepo, _unitOfWork, Substitute.For(), _cache); + var handler = new ResetAccountCommandHandler(_userRepo, accountResetRepo, _unitOfWork, _userDateService, _cache); var command = new ResetAccountCommand(UserId); var result = await handler.Handle(command, CancellationToken.None); @@ -338,7 +340,7 @@ public async Task ResetAccount_UserNotFound_ReturnsFailure() SetupUserNotFound(); var accountResetRepo = Substitute.For(); - var handler = new ResetAccountCommandHandler(_userRepo, accountResetRepo, _unitOfWork, Substitute.For(), _cache); + var handler = new ResetAccountCommandHandler(_userRepo, accountResetRepo, _unitOfWork, _userDateService, _cache); var command = new ResetAccountCommand(UserId); var result = await handler.Handle(command, CancellationToken.None); diff --git a/tests/Orbit.Application.Tests/Commands/Profile/ResetAccountCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Profile/ResetAccountCommandHandlerTests.cs index 52360a28..adca3f7f 100644 --- a/tests/Orbit.Application.Tests/Commands/Profile/ResetAccountCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Profile/ResetAccountCommandHandlerTests.cs @@ -14,13 +14,16 @@ public class ResetAccountCommandHandlerTests private readonly IAccountResetRepository _accountResetRepo = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly IUserDateService _userDateService = Substitute.For(); private readonly ResetAccountCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); + private static readonly DateOnly Today = new(2026, 3, 20); public ResetAccountCommandHandlerTests() { - _handler = new ResetAccountCommandHandler(_userRepo, _accountResetRepo, _unitOfWork, Substitute.For(), _cache); + _handler = new ResetAccountCommandHandler(_userRepo, _accountResetRepo, _unitOfWork, _userDateService, _cache); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()).Returns(Today); _unitOfWork.ExecuteInTransactionAsync( Arg.Any>(), Arg.Any()) diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs index fb53e500..fe33a56d 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs @@ -277,7 +277,7 @@ private static UpdateGoalProgressCommandHandler CreateGoalProgressHandler(OrbitD PassingGoalGate(), Substitute.For(), new UnitOfWork(context, new DatabaseConnectionSettings()), - Substitute.For(), + StubToday(new DateOnly(2026, 3, 20)), new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); diff --git a/tests/Orbit.Infrastructure.Tests/Services/SystemPromptBuilderTests.cs b/tests/Orbit.Infrastructure.Tests/Services/SystemPromptBuilderTests.cs index 77a300df..aaa5fb94 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/SystemPromptBuilderTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/SystemPromptBuilderTests.cs @@ -17,7 +17,7 @@ private static string BuildPrompt( DateOnly? userToday = null, IReadOnlyDictionary? habitMetrics = null) { ISystemPromptBuilder builder = new SystemPromptBuilder(); - var request = new PromptBuildRequest(habits, facts, hasImage, UserTags: userTags, UserToday: userToday, HabitMetrics: habitMetrics); + var request = new PromptBuildRequest(habits, facts, hasImage, UserTags: userTags, UserToday: userToday ?? new DateOnly(2026, 3, 20), HabitMetrics: habitMetrics); return builder.BuildStatic(request) + builder.BuildDynamic(request); } @@ -216,7 +216,7 @@ public void BuildDynamic_ContainsUserData_AndExcludesStaticRules() { ISystemPromptBuilder builder = new SystemPromptBuilder(); var habit = Habit.Create(new HabitCreateParams(TestUserId, "Morning Run", FrequencyUnit.Day, 1, DueDate: DateOnly.FromDateTime(DateTime.UtcNow))).Value; - var request = new PromptBuildRequest([habit], Array.Empty()); + var request = new PromptBuildRequest([habit], Array.Empty(), UserToday: new DateOnly(2026, 3, 20)); var dynamicPrompt = builder.BuildDynamic(request);