From ec4114706d5606f37e27f14b7aedc60d004a9f35 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 12 Jul 2026 16:52:40 -0300 Subject: [PATCH] perf(api): cache reference reads + invalidate goal AI and reference caches (#243) Goal mutation commands never cleared any AI cache, so goal-review (and goal-influenced summaries) stayed stale for up to an hour after a goal edit. Tag/ChecklistTemplate/UserFact/ApiKey reads are frequently-hit reference data that was uncached and, once cached, had no invalidation. - Add CacheInvalidationHelper.InvalidateGoalReviewCache and fold it into InvalidateUserAiCaches, so every habit AND goal mutation refreshes the goal-review cache (it derives from goals + linked-habit logs). - Call InvalidateUserAiCaches from all 8 Goal mutation commands, mirroring the habit commands. - Add a per-user IMemoryCache read layer to GetTags/GetChecklistTemplates/ GetUserFacts/GetApiKeys via centralized ReferenceCacheKeys, invalidated on every write path: the mutation commands, AI fact extraction (batch poller), and API-key usage (auth handler MarkUsed). Behavior-preserving: reads are fresh immediately after any mutation; a short backstop TTL bounds staleness from any future unhandled write path. Refs thomasluizon/orbit-ui-mobile#243 Co-Authored-By: Claude Opus 4.8 --- .../ApiKeyAuthenticationHandler.cs | 5 + .../ApiKeys/Commands/CreateApiKeyCommand.cs | 6 +- .../ApiKeys/Commands/RevokeApiKeyCommand.cs | 6 +- .../ApiKeys/Queries/GetApiKeysQuery.cs | 13 +- .../CreateChecklistTemplateCommand.cs | 6 +- .../DeleteChecklistTemplateCommand.cs | 7 +- .../Queries/GetChecklistTemplatesQuery.cs | 14 +- .../Common/CacheInvalidationHelper.cs | 22 ++- .../Common/ReferenceCacheKeys.cs | 24 +++ .../Goals/Commands/CreateGoalCommand.cs | 4 + .../Goals/Commands/DeleteGoalCommand.cs | 6 +- .../Goals/Commands/LinkHabitsToGoalCommand.cs | 7 +- .../Goals/Commands/ReorderGoalsCommand.cs | 7 +- .../Goals/Commands/RestoreGoalCommand.cs | 6 +- .../Goals/Commands/UpdateGoalCommand.cs | 4 + .../Commands/UpdateGoalProgressCommand.cs | 4 + .../Goals/Commands/UpdateGoalStatusCommand.cs | 4 + .../Tags/Commands/CreateTagCommand.cs | 6 +- .../Tags/Commands/DeleteTagCommand.cs | 7 +- .../Tags/Commands/RestoreTagCommand.cs | 7 +- .../Tags/Commands/UpdateTagCommand.cs | 7 +- .../Tags/Queries/GetTagsQuery.cs | 14 +- .../Commands/BulkDeleteUserFactsCommand.cs | 7 +- .../Commands/DeleteUserFactCommand.cs | 6 +- .../UserFacts/Queries/GetUserFactsQuery.cs | 13 +- .../Services/OpenAiBatchPollerService.cs | 19 ++- .../Caching/GoalAiCacheInvalidationTests.cs | 70 +++++++++ .../ReferenceCacheInvalidationTests.cs | 137 ++++++++++++++++++ .../CreateApiKeyCommandHandlerTests.cs | 4 +- .../RevokeApiKeyCommandHandlerTests.cs | 4 +- ...ateChecklistTemplateCommandHandlerTests.cs | 4 +- ...eteChecklistTemplateCommandHandlerTests.cs | 4 +- .../Goals/CreateGoalCommandHandlerTests.cs | 4 +- .../Goals/DeleteGoalCommandHandlerTests.cs | 4 +- .../LinkHabitsToGoalCommandHandlerTests.cs | 4 +- .../Goals/ReorderGoalsCommandHandlerTests.cs | 4 +- .../Goals/RestoreGoalCommandHandlerTests.cs | 4 +- .../Goals/UpdateGoalCommandHandlerTests.cs | 4 +- .../UpdateGoalProgressCommandHandlerTests.cs | 4 +- .../UpdateGoalStatusCommandHandlerTests.cs | 4 +- .../Tags/RestoreTagCommandHandlerTests.cs | 4 +- .../Commands/Tags/TagCommandHandlerTests.cs | 13 +- .../UserFacts/UserFactCommandHandlerTests.cs | 5 +- .../ApiKeys/GetApiKeysQueryHandlerTests.cs | 4 +- .../GetChecklistTemplatesQueryHandlerTests.cs | 4 +- .../Queries/Tags/GetTagsQueryHandlerTests.cs | 4 +- .../GetUserFactsQueryHandlerTests.cs | 4 +- .../ApiKeyAuthenticationHandlerTests.cs | 1 + .../ScheduledJobRegistryTests.cs | 3 +- .../Persistence/ConcurrencyRetryTests.cs | 2 + .../Services/OpenAiBatchPollerServiceTests.cs | 3 +- 51 files changed, 479 insertions(+), 54 deletions(-) create mode 100644 src/Orbit.Application/Common/ReferenceCacheKeys.cs create mode 100644 tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs create mode 100644 tests/Orbit.Application.Tests/Caching/ReferenceCacheInvalidationTests.cs diff --git a/src/Orbit.Api/Authentication/ApiKeyAuthenticationHandler.cs b/src/Orbit.Api/Authentication/ApiKeyAuthenticationHandler.cs index 964b98bc..295c4a74 100644 --- a/src/Orbit.Api/Authentication/ApiKeyAuthenticationHandler.cs +++ b/src/Orbit.Api/Authentication/ApiKeyAuthenticationHandler.cs @@ -1,7 +1,9 @@ using System.Security.Claims; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; +using Orbit.Application.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -32,6 +34,7 @@ protected override async Task HandleAuthenticateAsync() var apiKeyRepository = scope.ServiceProvider.GetRequiredService>(); var payGate = scope.ServiceProvider.GetRequiredService(); var unitOfWork = scope.ServiceProvider.GetRequiredService(); + var cache = scope.ServiceProvider.GetRequiredService(); var candidates = await apiKeyRepository.FindTrackedAsync( k => k.KeyPrefix == keyPrefix && !k.IsRevoked); @@ -52,6 +55,8 @@ protected override async Task HandleAuthenticateAsync() candidate.MarkUsed(); await unitOfWork.SaveChangesAsync(); + cache.Remove(ReferenceCacheKeys.ApiKeys(candidate.UserId)); + var claims = new List { new(ClaimTypes.NameIdentifier, candidate.UserId.ToString()), diff --git a/src/Orbit.Application/ApiKeys/Commands/CreateApiKeyCommand.cs b/src/Orbit.Application/ApiKeys/Commands/CreateApiKeyCommand.cs index dbea8f5f..a1015ee0 100644 --- a/src/Orbit.Application/ApiKeys/Commands/CreateApiKeyCommand.cs +++ b/src/Orbit.Application/ApiKeys/Commands/CreateApiKeyCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -26,7 +27,8 @@ public record CreateApiKeyCommand( public class CreateApiKeyCommandHandler( IGenericRepository apiKeyRepository, IPayGateService payGate, - IUnitOfWork unitOfWork) : IRequestHandler> + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler> { private const int MaxActiveKeys = 5; @@ -57,6 +59,8 @@ public async Task> Handle(CreateApiKeyCommand reque await apiKeyRepository.AddAsync(apiKey, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); + cache.Remove(ReferenceCacheKeys.ApiKeys(request.UserId)); + return Result.Success(new CreateApiKeyResponse( apiKey.Id, apiKey.Name, diff --git a/src/Orbit.Application/ApiKeys/Commands/RevokeApiKeyCommand.cs b/src/Orbit.Application/ApiKeys/Commands/RevokeApiKeyCommand.cs index 6464ac17..fc50c687 100644 --- a/src/Orbit.Application/ApiKeys/Commands/RevokeApiKeyCommand.cs +++ b/src/Orbit.Application/ApiKeys/Commands/RevokeApiKeyCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -13,7 +14,8 @@ public record RevokeApiKeyCommand( public class RevokeApiKeyCommandHandler( IGenericRepository apiKeyRepository, IPayGateService payGate, - IUnitOfWork unitOfWork) : IRequestHandler + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler { public async Task Handle(RevokeApiKeyCommand request, CancellationToken cancellationToken) { @@ -32,6 +34,8 @@ public async Task Handle(RevokeApiKeyCommand request, CancellationToken apiKey.Revoke(); await unitOfWork.SaveChangesAsync(cancellationToken); + cache.Remove(ReferenceCacheKeys.ApiKeys(request.UserId)); + return Result.Success(); } } diff --git a/src/Orbit.Application/ApiKeys/Queries/GetApiKeysQuery.cs b/src/Orbit.Application/ApiKeys/Queries/GetApiKeysQuery.cs index 31181fb1..e9092a90 100644 --- a/src/Orbit.Application/ApiKeys/Queries/GetApiKeysQuery.cs +++ b/src/Orbit.Application/ApiKeys/Queries/GetApiKeysQuery.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -21,7 +22,8 @@ public record GetApiKeysQuery(Guid UserId) : IRequest apiKeyRepository, - IPayGateService payGate) : IRequestHandler>> + IPayGateService payGate, + IMemoryCache cache) : IRequestHandler>> { public async Task>> Handle(GetApiKeysQuery request, CancellationToken cancellationToken) { @@ -29,6 +31,10 @@ public async Task>> Handle(GetApiKeysQuery if (gateCheck.IsFailure) return gateCheck.PropagateError>(); + var cacheKey = ReferenceCacheKeys.ApiKeys(request.UserId); + if (cache.TryGetValue(cacheKey, out IReadOnlyList? cached) && cached is not null) + return Result.Success(cached); + var keys = await apiKeyRepository.FindAsync( k => k.UserId == request.UserId, cancellationToken); @@ -47,6 +53,11 @@ public async Task>> Handle(GetApiKeysQuery k.IsRevoked)) .ToList(); + cache.Set(cacheKey, (IReadOnlyList)result, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ReferenceCacheKeys.Ttl + }); + return Result.Success>(result); } } diff --git a/src/Orbit.Application/ChecklistTemplates/Commands/CreateChecklistTemplateCommand.cs b/src/Orbit.Application/ChecklistTemplates/Commands/CreateChecklistTemplateCommand.cs index 1218cf93..ad53f8fa 100644 --- a/src/Orbit.Application/ChecklistTemplates/Commands/CreateChecklistTemplateCommand.cs +++ b/src/Orbit.Application/ChecklistTemplates/Commands/CreateChecklistTemplateCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -13,7 +14,8 @@ public record CreateChecklistTemplateCommand( public class CreateChecklistTemplateCommandHandler( IGenericRepository repository, - IUnitOfWork unitOfWork) : IRequestHandler> + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler> { public async Task> Handle(CreateChecklistTemplateCommand request, CancellationToken cancellationToken) { @@ -24,6 +26,8 @@ public async Task> Handle(CreateChecklistTemplateCommand request, C await repository.AddAsync(result.Value, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); + cache.Remove(ReferenceCacheKeys.ChecklistTemplates(request.UserId)); + return Result.Success(result.Value.Id); } } diff --git a/src/Orbit.Application/ChecklistTemplates/Commands/DeleteChecklistTemplateCommand.cs b/src/Orbit.Application/ChecklistTemplates/Commands/DeleteChecklistTemplateCommand.cs index 230582cb..a6471790 100644 --- a/src/Orbit.Application/ChecklistTemplates/Commands/DeleteChecklistTemplateCommand.cs +++ b/src/Orbit.Application/ChecklistTemplates/Commands/DeleteChecklistTemplateCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -12,7 +13,8 @@ public record DeleteChecklistTemplateCommand( public class DeleteChecklistTemplateCommandHandler( IGenericRepository repository, - IUnitOfWork unitOfWork) : IRequestHandler + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler { public async Task Handle(DeleteChecklistTemplateCommand request, CancellationToken cancellationToken) { @@ -25,6 +27,9 @@ public async Task Handle(DeleteChecklistTemplateCommand request, Cancell template.SoftDelete(); await unitOfWork.SaveChangesAsync(cancellationToken); + + cache.Remove(ReferenceCacheKeys.ChecklistTemplates(request.UserId)); + return Result.Success(); } } diff --git a/src/Orbit.Application/ChecklistTemplates/Queries/GetChecklistTemplatesQuery.cs b/src/Orbit.Application/ChecklistTemplates/Queries/GetChecklistTemplatesQuery.cs index c69217bd..927b78a4 100644 --- a/src/Orbit.Application/ChecklistTemplates/Queries/GetChecklistTemplatesQuery.cs +++ b/src/Orbit.Application/ChecklistTemplates/Queries/GetChecklistTemplatesQuery.cs @@ -1,4 +1,6 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -13,10 +15,15 @@ public record ChecklistTemplateResponse( public record GetChecklistTemplatesQuery(Guid UserId) : IRequest>>; public class GetChecklistTemplatesQueryHandler( - IGenericRepository repository) : IRequestHandler>> + IGenericRepository repository, + IMemoryCache cache) : IRequestHandler>> { public async Task>> Handle(GetChecklistTemplatesQuery request, CancellationToken cancellationToken) { + var cacheKey = ReferenceCacheKeys.ChecklistTemplates(request.UserId); + if (cache.TryGetValue(cacheKey, out IReadOnlyList? cached) && cached is not null) + return Result.Success(cached); + var templates = await repository.FindAsync( t => t.UserId == request.UserId, cancellationToken); @@ -26,6 +33,11 @@ public async Task>> Handle(GetCh .Select(t => new ChecklistTemplateResponse(t.Id, t.Name, t.Items)) .ToList(); + cache.Set(cacheKey, (IReadOnlyList)result, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ReferenceCacheKeys.Ttl + }); + return Result.Success>(result); } } diff --git a/src/Orbit.Application/Common/CacheInvalidationHelper.cs b/src/Orbit.Application/Common/CacheInvalidationHelper.cs index bdbaf61d..6ef6753a 100644 --- a/src/Orbit.Application/Common/CacheInvalidationHelper.cs +++ b/src/Orbit.Application/Common/CacheInvalidationHelper.cs @@ -9,7 +9,8 @@ public static class CacheInvalidationHelper public static void InvalidateSummaryCache(IMemoryCache cache, Guid userId) { - var today = DateOnly.FromDateTime(DateTime.UtcNow); + var nowAtUtc = DateTime.UtcNow; + var today = DateOnly.FromDateTime(nowAtUtc); for (int i = -2; i <= 2; i++) { var date = today.AddDays(i); @@ -29,7 +30,8 @@ public static void InvalidateSummaryCache(IMemoryCache cache, Guid userId) /// public static void InvalidateRetrospectiveCache(IMemoryCache cache, Guid userId) { - var today = DateOnly.FromDateTime(DateTime.UtcNow); + var nowAtUtc = DateTime.UtcNow; + var today = DateOnly.FromDateTime(nowAtUtc); for (int i = -2; i <= 2; i++) { var date = today.AddDays(i); @@ -40,12 +42,24 @@ public static void InvalidateRetrospectiveCache(IMemoryCache cache, Guid userId) } /// - /// Convenience: invalidate both summary and retrospective caches for a user. Use this from - /// any mutation command that affects habits, logs, or goals. + /// Invalidate the per-user, per-language goal-review cache. The review is derived from the + /// user's active goals and their linked habits' logs, so any goal or habit mutation must clear + /// it, otherwise users see a stale 1-hour-old review after editing a goal or logging a habit. + /// + public static void InvalidateGoalReviewCache(IMemoryCache cache, Guid userId) + { + foreach (var lang in AppConstants.SupportedLanguages) + cache.Remove($"goal-review:{userId}:{lang}"); + } + + /// + /// 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) { InvalidateSummaryCache(cache, userId); InvalidateRetrospectiveCache(cache, userId); + InvalidateGoalReviewCache(cache, userId); } } diff --git a/src/Orbit.Application/Common/ReferenceCacheKeys.cs b/src/Orbit.Application/Common/ReferenceCacheKeys.cs new file mode 100644 index 00000000..4490fe1b --- /dev/null +++ b/src/Orbit.Application/Common/ReferenceCacheKeys.cs @@ -0,0 +1,24 @@ +namespace Orbit.Application.Common; + +/// +/// Cache keys and backstop TTL for per-user reference-data reads (tags, checklist templates, user +/// facts, API keys). Centralized so the read query and every mutation that invalidates it share a +/// single key definition and cannot drift apart. +/// +public static class ReferenceCacheKeys +{ + /// + /// Backstop expiry for reference-data cache entries. Every known write path invalidates its key + /// explicitly, so this only bounds staleness in the event a future write path forgets to; kept + /// short for that safety margin. + /// + public static readonly TimeSpan Ttl = TimeSpan.FromMinutes(10); + + public static string Tags(Guid userId) => $"tags:{userId}"; + + public static string ChecklistTemplates(Guid userId) => $"checklist-templates:{userId}"; + + public static string UserFacts(Guid userId) => $"user-facts:{userId}"; + + public static string ApiKeys(Guid userId) => $"api-keys:{userId}"; +} diff --git a/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs b/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs index 487f8e9b..14bfb2c3 100644 --- a/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/CreateGoalCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Orbit.Application.Common; using Orbit.Domain.Common; @@ -24,6 +25,7 @@ public partial class CreateGoalCommandHandler( IUserDateService userDateService, IGamificationService gamificationService, IUnitOfWork unitOfWork, + IMemoryCache cache, ILogger logger) : IRequestHandler> { public async Task> Handle(CreateGoalCommand request, CancellationToken cancellationToken) @@ -65,6 +67,8 @@ public async Task> Handle(CreateGoalCommand request, CancellationTo LogGamificationGoalCreationFailed(logger, ex, request.UserId); } + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + return Result.Success(goal.Id); } diff --git a/src/Orbit.Application/Goals/Commands/DeleteGoalCommand.cs b/src/Orbit.Application/Goals/Commands/DeleteGoalCommand.cs index 0524e3d3..07e2ac83 100644 --- a/src/Orbit.Application/Goals/Commands/DeleteGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/DeleteGoalCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Behaviors; using Orbit.Application.Common; using Orbit.Domain.Common; @@ -14,7 +15,8 @@ public record DeleteGoalCommand( public class DeleteGoalCommandHandler( IGenericRepository goalRepository, IPayGateService payGate, - IUnitOfWork unitOfWork) : IRequestHandler + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler { public async Task Handle(DeleteGoalCommand request, CancellationToken cancellationToken) { @@ -32,6 +34,8 @@ public async Task Handle(DeleteGoalCommand request, CancellationToken ca goal.SoftDelete(); await unitOfWork.SaveChangesAsync(cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + return Result.Success(); } } diff --git a/src/Orbit.Application/Goals/Commands/LinkHabitsToGoalCommand.cs b/src/Orbit.Application/Goals/Commands/LinkHabitsToGoalCommand.cs index efc3a51f..71bb6583 100644 --- a/src/Orbit.Application/Goals/Commands/LinkHabitsToGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/LinkHabitsToGoalCommand.cs @@ -1,5 +1,6 @@ using MediatR; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Behaviors; using Orbit.Application.Common; using Orbit.Domain.Common; @@ -17,7 +18,8 @@ public class LinkHabitsToGoalCommandHandler( IGenericRepository goalRepository, IGenericRepository habitRepository, IPayGateService payGate, - IUnitOfWork unitOfWork) : IRequestHandler + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler { public async Task Handle(LinkHabitsToGoalCommand request, CancellationToken cancellationToken) { @@ -51,6 +53,9 @@ public async Task Handle(LinkHabitsToGoalCommand request, CancellationTo goal.AddHabit(habit); await unitOfWork.SaveChangesAsync(cancellationToken); + + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + return Result.Success(); } } diff --git a/src/Orbit.Application/Goals/Commands/ReorderGoalsCommand.cs b/src/Orbit.Application/Goals/Commands/ReorderGoalsCommand.cs index 28305163..418bc5f1 100644 --- a/src/Orbit.Application/Goals/Commands/ReorderGoalsCommand.cs +++ b/src/Orbit.Application/Goals/Commands/ReorderGoalsCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Behaviors; using Orbit.Application.Common; using Orbit.Domain.Common; @@ -16,7 +17,8 @@ public record ReorderGoalsCommand( public class ReorderGoalsCommandHandler( IGenericRepository goalRepository, IPayGateService payGate, - IUnitOfWork unitOfWork) : IRequestHandler + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler { public async Task Handle(ReorderGoalsCommand request, CancellationToken cancellationToken) { @@ -47,6 +49,9 @@ public async Task Handle(ReorderGoalsCommand request, CancellationToken } await unitOfWork.SaveChangesAsync(cancellationToken); + + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + return Result.Success(); } } diff --git a/src/Orbit.Application/Goals/Commands/RestoreGoalCommand.cs b/src/Orbit.Application/Goals/Commands/RestoreGoalCommand.cs index bb65efcf..18eeaccc 100644 --- a/src/Orbit.Application/Goals/Commands/RestoreGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/RestoreGoalCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Behaviors; using Orbit.Application.Common; using Orbit.Domain.Common; @@ -14,7 +15,8 @@ public record RestoreGoalCommand( public class RestoreGoalCommandHandler( IGenericRepository goalRepository, IPayGateService payGate, - IUnitOfWork unitOfWork) : IRequestHandler + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler { public async Task Handle(RestoreGoalCommand request, CancellationToken cancellationToken) { @@ -33,6 +35,8 @@ public async Task Handle(RestoreGoalCommand request, CancellationToken c goal.Restore(); await unitOfWork.SaveChangesAsync(cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + return Result.Success(); } } diff --git a/src/Orbit.Application/Goals/Commands/UpdateGoalCommand.cs b/src/Orbit.Application/Goals/Commands/UpdateGoalCommand.cs index 1be9ca4c..1db30bcf 100644 --- a/src/Orbit.Application/Goals/Commands/UpdateGoalCommand.cs +++ b/src/Orbit.Application/Goals/Commands/UpdateGoalCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Orbit.Application.Behaviors; using Orbit.Application.Common; @@ -25,6 +26,7 @@ public partial class UpdateGoalCommandHandler( IUserDateService userDateService, IGamificationService gamificationService, IUnitOfWork unitOfWork, + IMemoryCache cache, ILogger logger) : IRequestHandler { public async Task Handle(UpdateGoalCommand request, CancellationToken cancellationToken) @@ -62,6 +64,8 @@ public async Task Handle(UpdateGoalCommand request, CancellationToken ca if (result.Value == GoalEditTransition.Completed) await ProcessGoalCompletionSafeAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + return Result.Success(); } diff --git a/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs b/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs index effb3a71..277ae268 100644 --- a/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs +++ b/src/Orbit.Application/Goals/Commands/UpdateGoalProgressCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Orbit.Application.Common; using Orbit.Domain.Common; @@ -19,6 +20,7 @@ public partial class UpdateGoalProgressCommandHandler( IPayGateService payGate, IGamificationService gamificationService, IUnitOfWork unitOfWork, + IMemoryCache cache, ILogger logger) : IRequestHandler { public async Task Handle(UpdateGoalProgressCommand request, CancellationToken cancellationToken) @@ -55,6 +57,8 @@ public async Task Handle(UpdateGoalProgressCommand request, Cancellation if (justCompleted) await ProcessGoalCompletionSafeAsync(request.UserId, cancellationToken); + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + return Result.Success(); } diff --git a/src/Orbit.Application/Goals/Commands/UpdateGoalStatusCommand.cs b/src/Orbit.Application/Goals/Commands/UpdateGoalStatusCommand.cs index f70fe334..b0e7a0bc 100644 --- a/src/Orbit.Application/Goals/Commands/UpdateGoalStatusCommand.cs +++ b/src/Orbit.Application/Goals/Commands/UpdateGoalStatusCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Orbit.Application.Behaviors; using Orbit.Application.Common; @@ -19,6 +20,7 @@ public partial class UpdateGoalStatusCommandHandler( IPayGateService payGate, IGamificationService gamificationService, IUnitOfWork unitOfWork, + IMemoryCache cache, ILogger logger) : IRequestHandler { public async Task Handle(UpdateGoalStatusCommand request, CancellationToken cancellationToken) @@ -58,6 +60,8 @@ public async Task Handle(UpdateGoalStatusCommand request, CancellationTo } } + CacheInvalidationHelper.InvalidateUserAiCaches(cache, request.UserId); + return Result.Success(); } diff --git a/src/Orbit.Application/Tags/Commands/CreateTagCommand.cs b/src/Orbit.Application/Tags/Commands/CreateTagCommand.cs index e269dfae..45417ff0 100644 --- a/src/Orbit.Application/Tags/Commands/CreateTagCommand.cs +++ b/src/Orbit.Application/Tags/Commands/CreateTagCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -13,7 +14,8 @@ public record CreateTagCommand( public class CreateTagCommandHandler( IGenericRepository tagRepository, - IUnitOfWork unitOfWork) : IRequestHandler> + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler> { public async Task> Handle(CreateTagCommand request, CancellationToken cancellationToken) { @@ -32,6 +34,8 @@ public async Task> Handle(CreateTagCommand request, CancellationTok await tagRepository.AddAsync(result.Value, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); + cache.Remove(ReferenceCacheKeys.Tags(request.UserId)); + return Result.Success(result.Value.Id); } } diff --git a/src/Orbit.Application/Tags/Commands/DeleteTagCommand.cs b/src/Orbit.Application/Tags/Commands/DeleteTagCommand.cs index cfe98176..34578087 100644 --- a/src/Orbit.Application/Tags/Commands/DeleteTagCommand.cs +++ b/src/Orbit.Application/Tags/Commands/DeleteTagCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -12,7 +13,8 @@ public record DeleteTagCommand( public class DeleteTagCommandHandler( IGenericRepository tagRepository, - IUnitOfWork unitOfWork) : IRequestHandler + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler { public async Task Handle(DeleteTagCommand request, CancellationToken cancellationToken) { @@ -25,6 +27,9 @@ public async Task Handle(DeleteTagCommand request, CancellationToken can tag.SoftDelete(); await unitOfWork.SaveChangesAsync(cancellationToken); + + cache.Remove(ReferenceCacheKeys.Tags(request.UserId)); + return Result.Success(); } } diff --git a/src/Orbit.Application/Tags/Commands/RestoreTagCommand.cs b/src/Orbit.Application/Tags/Commands/RestoreTagCommand.cs index 297f7e51..eac8f325 100644 --- a/src/Orbit.Application/Tags/Commands/RestoreTagCommand.cs +++ b/src/Orbit.Application/Tags/Commands/RestoreTagCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Behaviors; using Orbit.Application.Common; using Orbit.Domain.Common; @@ -13,7 +14,8 @@ public record RestoreTagCommand( public class RestoreTagCommandHandler( IGenericRepository tagRepository, - IUnitOfWork unitOfWork) : IRequestHandler + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler { public async Task Handle(RestoreTagCommand request, CancellationToken cancellationToken) { @@ -27,6 +29,9 @@ public async Task Handle(RestoreTagCommand request, CancellationToken ca tag.Restore(); await unitOfWork.SaveChangesAsync(cancellationToken); + + cache.Remove(ReferenceCacheKeys.Tags(request.UserId)); + return Result.Success(); } } diff --git a/src/Orbit.Application/Tags/Commands/UpdateTagCommand.cs b/src/Orbit.Application/Tags/Commands/UpdateTagCommand.cs index dc18ba59..d89b6520 100644 --- a/src/Orbit.Application/Tags/Commands/UpdateTagCommand.cs +++ b/src/Orbit.Application/Tags/Commands/UpdateTagCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -14,7 +15,8 @@ public record UpdateTagCommand( public class UpdateTagCommandHandler( IGenericRepository tagRepository, - IUnitOfWork unitOfWork) : IRequestHandler + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler { public async Task Handle(UpdateTagCommand request, CancellationToken cancellationToken) { @@ -37,6 +39,9 @@ public async Task Handle(UpdateTagCommand request, CancellationToken can return result; await unitOfWork.SaveChangesAsync(cancellationToken); + + cache.Remove(ReferenceCacheKeys.Tags(request.UserId)); + return Result.Success(); } } diff --git a/src/Orbit.Application/Tags/Queries/GetTagsQuery.cs b/src/Orbit.Application/Tags/Queries/GetTagsQuery.cs index b107f9e2..b870d78d 100644 --- a/src/Orbit.Application/Tags/Queries/GetTagsQuery.cs +++ b/src/Orbit.Application/Tags/Queries/GetTagsQuery.cs @@ -1,4 +1,6 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; +using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -13,10 +15,15 @@ public record TagResponse( public record GetTagsQuery(Guid UserId) : IRequest>>; public class GetTagsQueryHandler( - IGenericRepository tagRepository) : IRequestHandler>> + IGenericRepository tagRepository, + IMemoryCache cache) : IRequestHandler>> { public async Task>> Handle(GetTagsQuery request, CancellationToken cancellationToken) { + var cacheKey = ReferenceCacheKeys.Tags(request.UserId); + if (cache.TryGetValue(cacheKey, out IReadOnlyList? cached) && cached is not null) + return Result.Success(cached); + var tags = await tagRepository.FindAsync( t => t.UserId == request.UserId, cancellationToken); @@ -26,6 +33,11 @@ public async Task>> Handle(GetTagsQuery reques .Select(t => new TagResponse(t.Id, t.Name, t.Color)) .ToList(); + cache.Set(cacheKey, (IReadOnlyList)result, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ReferenceCacheKeys.Ttl + }); + return Result.Success>(result); } } diff --git a/src/Orbit.Application/UserFacts/Commands/BulkDeleteUserFactsCommand.cs b/src/Orbit.Application/UserFacts/Commands/BulkDeleteUserFactsCommand.cs index 15e7b2be..c78f6c63 100644 --- a/src/Orbit.Application/UserFacts/Commands/BulkDeleteUserFactsCommand.cs +++ b/src/Orbit.Application/UserFacts/Commands/BulkDeleteUserFactsCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -13,7 +14,8 @@ public record BulkDeleteUserFactsCommand( public class BulkDeleteUserFactsCommandHandler( IGenericRepository userFactRepository, IPayGateService payGate, - IUnitOfWork unitOfWork) : IRequestHandler> + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler> { public async Task> Handle(BulkDeleteUserFactsCommand request, CancellationToken cancellationToken) { @@ -30,7 +32,10 @@ public async Task> Handle(BulkDeleteUserFactsCommand request, Cancel fact.SoftDelete(); if (facts.Count > 0) + { await unitOfWork.SaveChangesAsync(cancellationToken); + cache.Remove(ReferenceCacheKeys.UserFacts(request.UserId)); + } return Result.Success(facts.Count); } diff --git a/src/Orbit.Application/UserFacts/Commands/DeleteUserFactCommand.cs b/src/Orbit.Application/UserFacts/Commands/DeleteUserFactCommand.cs index 952ad92b..f6a80e34 100644 --- a/src/Orbit.Application/UserFacts/Commands/DeleteUserFactCommand.cs +++ b/src/Orbit.Application/UserFacts/Commands/DeleteUserFactCommand.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -11,7 +12,8 @@ public record DeleteUserFactCommand(Guid UserId, Guid FactId) : IRequest public class DeleteUserFactCommandHandler( IGenericRepository userFactRepository, IPayGateService payGate, - IUnitOfWork unitOfWork) : IRequestHandler + IUnitOfWork unitOfWork, + IMemoryCache cache) : IRequestHandler { public async Task Handle(DeleteUserFactCommand request, CancellationToken cancellationToken) { @@ -29,6 +31,8 @@ public async Task Handle(DeleteUserFactCommand request, CancellationToke fact.SoftDelete(); await unitOfWork.SaveChangesAsync(cancellationToken); + cache.Remove(ReferenceCacheKeys.UserFacts(request.UserId)); + return Result.Success(); } } diff --git a/src/Orbit.Application/UserFacts/Queries/GetUserFactsQuery.cs b/src/Orbit.Application/UserFacts/Queries/GetUserFactsQuery.cs index 4bcb477d..ddd86a64 100644 --- a/src/Orbit.Application/UserFacts/Queries/GetUserFactsQuery.cs +++ b/src/Orbit.Application/UserFacts/Queries/GetUserFactsQuery.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.Extensions.Caching.Memory; using Orbit.Application.Common; using Orbit.Domain.Common; using Orbit.Domain.Entities; @@ -17,7 +18,8 @@ public record UserFactDto( public class GetUserFactsQueryHandler( IGenericRepository userFactRepository, - IPayGateService payGate) : IRequestHandler>> + IPayGateService payGate, + IMemoryCache cache) : IRequestHandler>> { public async Task>> Handle(GetUserFactsQuery request, CancellationToken cancellationToken) { @@ -25,6 +27,10 @@ public async Task>> Handle(GetUserFactsQuery r if (gateCheck.IsFailure) return gateCheck.PropagateError>(); + var cacheKey = ReferenceCacheKeys.UserFacts(request.UserId); + if (cache.TryGetValue(cacheKey, out IReadOnlyList? cached) && cached is not null) + return Result.Success(cached); + var facts = await userFactRepository.FindAsync( f => f.UserId == request.UserId, cancellationToken); @@ -39,6 +45,11 @@ public async Task>> Handle(GetUserFactsQuery r f.UpdatedAtUtc)) .ToList(); + cache.Set(cacheKey, (IReadOnlyList)result, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ReferenceCacheKeys.Ttl + }); + return Result.Success>(result); } } diff --git a/src/Orbit.Infrastructure/Services/OpenAiBatchPollerService.cs b/src/Orbit.Infrastructure/Services/OpenAiBatchPollerService.cs index 7605b6be..df070cb6 100644 --- a/src/Orbit.Infrastructure/Services/OpenAiBatchPollerService.cs +++ b/src/Orbit.Infrastructure/Services/OpenAiBatchPollerService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -22,7 +23,8 @@ namespace Orbit.Infrastructure.Services; public sealed partial class OpenAiBatchPollerService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : ScheduledServiceBase, IScheduledJob + IConfiguration configuration, + IMemoryCache cache) : ScheduledServiceBase, IScheduledJob { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; @@ -100,11 +102,14 @@ private async Task CompleteBatchAsync( var outputJsonl = await batchClient.DownloadFileAsync(outputFileId, ct); var facts = ParseExtractedFacts(outputJsonl); - await PersistFactsAsync(batch.UserId, facts, userFactRepository, appConfig, ct); + var persistedFactCount = await PersistFactsAsync(batch.UserId, facts, userFactRepository, appConfig, ct); batch.MarkCompleted(outputFileId); await unitOfWork.SaveChangesAsync(ct); + if (persistedFactCount > 0) + cache.Remove(ReferenceCacheKeys.UserFacts(batch.UserId)); + await DeleteFilesAsync(batchClient, batch.InputFileId, outputFileId, ct); if (logger.IsEnabled(LogLevel.Information)) @@ -126,7 +131,7 @@ private async Task FailBatchAsync( LogBatchFailed(logger, batch.BatchId, status.Status); } - private async Task PersistFactsAsync( + private static async Task PersistFactsAsync( Guid userId, IReadOnlyList candidates, IGenericRepository userFactRepository, @@ -134,19 +139,20 @@ private async Task PersistFactsAsync( CancellationToken ct) { if (candidates.Count == 0) - return; + return 0; var existingFacts = await userFactRepository.FindAsync(f => f.UserId == userId && !f.IsDeleted, ct); var existingFactCount = existingFacts.Count; var maxFacts = await appConfig.GetAsync(AppConfigKeys.MaxUserFacts, AppConstants.MaxUserFacts, ct); if (existingFactCount >= maxFacts) - return; + return 0; var existingTexts = existingFacts .Select(f => f.FactText.Trim().ToLowerInvariant()) .ToHashSet(); + var added = 0; var remaining = maxFacts - existingFactCount; foreach (var candidate in candidates) { @@ -160,8 +166,11 @@ private async Task PersistFactsAsync( { await userFactRepository.AddAsync(factResult.Value, ct); remaining--; + added++; } } + + return added; } internal static IReadOnlyList ParseExtractedFacts(string outputJsonl) diff --git a/tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs b/tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs new file mode 100644 index 00000000..472334f2 --- /dev/null +++ b/tests/Orbit.Application.Tests/Caching/GoalAiCacheInvalidationTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Application.Goals.Commands; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Caching; + +public class GoalAiCacheInvalidationTests +{ + private static readonly Guid UserId = Guid.NewGuid(); + + private static string GoalReviewKey(string language) => $"goal-review:{UserId}:{language}"; + + [Fact] + public void InvalidateGoalReviewCache_RemovesEntryForEverySupportedLanguage() + { + var cache = new MemoryCache(new MemoryCacheOptions()); + foreach (var language in AppConstants.SupportedLanguages) + cache.Set(GoalReviewKey(language), "cached review"); + + CacheInvalidationHelper.InvalidateGoalReviewCache(cache, UserId); + + foreach (var language in AppConstants.SupportedLanguages) + cache.TryGetValue(GoalReviewKey(language), out _).Should().BeFalse(); + } + + [Fact] + public void InvalidateUserAiCaches_AlsoClearsGoalReview() + { + var cache = new MemoryCache(new MemoryCacheOptions()); + cache.Set(GoalReviewKey("en"), "cached review"); + + CacheInvalidationHelper.InvalidateUserAiCaches(cache, UserId); + + cache.TryGetValue(GoalReviewKey("en"), out _).Should().BeFalse(); + } + + [Fact] + public async Task CreateGoal_InvalidatesCachedGoalReview() + { + var cache = new MemoryCache(new MemoryCacheOptions()); + cache.Set(GoalReviewKey("en"), "stale review"); + + var goalRepo = Substitute.For>(); + var payGate = Substitute.For(); + var userDateService = Substitute.For(); + var gamificationService = Substitute.For(); + var unitOfWork = Substitute.For(); + + payGate.CanAccessGoals(Arg.Any(), Arg.Any()).Returns(Result.Success()); + userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()) + .Returns(new DateOnly(2026, 7, 12)); + + var handler = new CreateGoalCommandHandler( + goalRepo, payGate, userDateService, gamificationService, unitOfWork, cache, + Substitute.For>()); + + var result = await handler.Handle( + new CreateGoalCommand(UserId, "Run a marathon", null, 42.2m, "km", null), + CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + cache.TryGetValue(GoalReviewKey("en"), out _).Should().BeFalse(); + } +} diff --git a/tests/Orbit.Application.Tests/Caching/ReferenceCacheInvalidationTests.cs b/tests/Orbit.Application.Tests/Caching/ReferenceCacheInvalidationTests.cs new file mode 100644 index 00000000..ade28a3c --- /dev/null +++ b/tests/Orbit.Application.Tests/Caching/ReferenceCacheInvalidationTests.cs @@ -0,0 +1,137 @@ +using System.Linq.Expressions; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using NSubstitute; +using Orbit.Application.ApiKeys.Commands; +using Orbit.Application.ApiKeys.Queries; +using Orbit.Application.ChecklistTemplates.Commands; +using Orbit.Application.ChecklistTemplates.Queries; +using Orbit.Application.Tags.Commands; +using Orbit.Application.Tags.Queries; +using Orbit.Application.UserFacts.Commands; +using Orbit.Application.UserFacts.Queries; +using Orbit.Domain.Common; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; + +namespace Orbit.Application.Tests.Caching; + +public class ReferenceCacheInvalidationTests +{ + private static readonly Guid UserId = Guid.NewGuid(); + + [Fact] + public async Task Tags_Read_IsCached_AndMutationInvalidatesIt() + { + var cache = new MemoryCache(new MemoryCacheOptions()); + var repo = Substitute.For>(); + var unitOfWork = Substitute.For(); + var tag = Tag.Create(UserId, "Health", "#00ff00").Value; + + repo.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(new List { tag }.AsReadOnly()); + repo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(tag); + + var read = new GetTagsQueryHandler(repo, cache); + await read.Handle(new GetTagsQuery(UserId), CancellationToken.None); + await read.Handle(new GetTagsQuery(UserId), CancellationToken.None); + await repo.Received(1).FindAsync(Arg.Any>>(), Arg.Any()); + + var delete = new DeleteTagCommandHandler(repo, unitOfWork, cache); + (await delete.Handle(new DeleteTagCommand(UserId, tag.Id), CancellationToken.None)) + .IsSuccess.Should().BeTrue(); + + await read.Handle(new GetTagsQuery(UserId), CancellationToken.None); + await repo.Received(2).FindAsync(Arg.Any>>(), Arg.Any()); + } + + [Fact] + public async Task ChecklistTemplates_Read_IsCached_AndCreateInvalidatesIt() + { + var cache = new MemoryCache(new MemoryCacheOptions()); + var repo = Substitute.For>(); + var unitOfWork = Substitute.For(); + + repo.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns((IReadOnlyList)[]); + + var read = new GetChecklistTemplatesQueryHandler(repo, cache); + await read.Handle(new GetChecklistTemplatesQuery(UserId), CancellationToken.None); + await read.Handle(new GetChecklistTemplatesQuery(UserId), CancellationToken.None); + await repo.Received(1).FindAsync(Arg.Any>>(), Arg.Any()); + + var create = new CreateChecklistTemplateCommandHandler(repo, unitOfWork, cache); + (await create.Handle( + new CreateChecklistTemplateCommand(UserId, "Morning", ["Stretch", "Water"]), + CancellationToken.None)) + .IsSuccess.Should().BeTrue(); + + await read.Handle(new GetChecklistTemplatesQuery(UserId), CancellationToken.None); + await repo.Received(2).FindAsync(Arg.Any>>(), Arg.Any()); + } + + [Fact] + public async Task UserFacts_Read_IsCached_AndDeleteInvalidatesIt() + { + var cache = new MemoryCache(new MemoryCacheOptions()); + var repo = Substitute.For>(); + var payGate = Substitute.For(); + var unitOfWork = Substitute.For(); + var fact = UserFact.Create(UserId, "User is a vegetarian", "context").Value; + + payGate.CanReadUserFacts(Arg.Any(), Arg.Any()).Returns(Result.Success()); + payGate.CanManageUserFacts(Arg.Any(), Arg.Any()).Returns(Result.Success()); + repo.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(new List { fact }.AsReadOnly()); + repo.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(fact); + + var read = new GetUserFactsQueryHandler(repo, payGate, cache); + await read.Handle(new GetUserFactsQuery(UserId), CancellationToken.None); + await read.Handle(new GetUserFactsQuery(UserId), CancellationToken.None); + await repo.Received(1).FindAsync(Arg.Any>>(), Arg.Any()); + + var delete = new DeleteUserFactCommandHandler(repo, payGate, unitOfWork, cache); + (await delete.Handle(new DeleteUserFactCommand(UserId, fact.Id), CancellationToken.None)) + .IsSuccess.Should().BeTrue(); + + await read.Handle(new GetUserFactsQuery(UserId), CancellationToken.None); + await repo.Received(2).FindAsync(Arg.Any>>(), Arg.Any()); + } + + [Fact] + public async Task ApiKeys_Read_IsCached_AndRevokeInvalidatesIt() + { + var cache = new MemoryCache(new MemoryCacheOptions()); + var repo = Substitute.For>(); + var payGate = Substitute.For(); + var unitOfWork = Substitute.For(); + var (apiKey, _) = ApiKey.Create(UserId, "Agent Key").Value; + + payGate.CanReadApiKeys(Arg.Any(), Arg.Any()).Returns(Result.Success()); + payGate.CanManageApiKeys(Arg.Any(), Arg.Any()).Returns(Result.Success()); + repo.FindAsync(Arg.Any>>(), Arg.Any()) + .Returns(new List { apiKey }.AsReadOnly()); + repo.FindTrackedAsync(Arg.Any>>(), Arg.Any()) + .Returns(new List { apiKey }); + + var read = new GetApiKeysQueryHandler(repo, payGate, cache); + await read.Handle(new GetApiKeysQuery(UserId), CancellationToken.None); + await read.Handle(new GetApiKeysQuery(UserId), CancellationToken.None); + await repo.Received(1).FindAsync(Arg.Any>>(), Arg.Any()); + + var revoke = new RevokeApiKeyCommandHandler(repo, payGate, unitOfWork, cache); + (await revoke.Handle(new RevokeApiKeyCommand(UserId, apiKey.Id), CancellationToken.None)) + .IsSuccess.Should().BeTrue(); + + await read.Handle(new GetApiKeysQuery(UserId), CancellationToken.None); + await repo.Received(2).FindAsync(Arg.Any>>(), Arg.Any()); + } +} diff --git a/tests/Orbit.Application.Tests/Commands/ApiKeys/CreateApiKeyCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/ApiKeys/CreateApiKeyCommandHandlerTests.cs index 57867fc7..76f60862 100644 --- a/tests/Orbit.Application.Tests/Commands/ApiKeys/CreateApiKeyCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/ApiKeys/CreateApiKeyCommandHandlerTests.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.ApiKeys.Commands; using Orbit.Domain.Common; @@ -13,13 +14,14 @@ public class CreateApiKeyCommandHandlerTests private readonly IGenericRepository _apiKeyRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly CreateApiKeyCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); public CreateApiKeyCommandHandlerTests() { - _handler = new CreateApiKeyCommandHandler(_apiKeyRepo, _payGate, _unitOfWork); + _handler = new CreateApiKeyCommandHandler(_apiKeyRepo, _payGate, _unitOfWork, _cache); _payGate.CanCreateApiKeys(Arg.Any(), Arg.Any()) .Returns(Result.Success()); diff --git a/tests/Orbit.Application.Tests/Commands/ApiKeys/RevokeApiKeyCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/ApiKeys/RevokeApiKeyCommandHandlerTests.cs index fff55e61..ce9bd404 100644 --- a/tests/Orbit.Application.Tests/Commands/ApiKeys/RevokeApiKeyCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/ApiKeys/RevokeApiKeyCommandHandlerTests.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.ApiKeys.Commands; using Orbit.Application.Common; @@ -14,6 +15,7 @@ public class RevokeApiKeyCommandHandlerTests private readonly IGenericRepository _apiKeyRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly RevokeApiKeyCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -22,7 +24,7 @@ public RevokeApiKeyCommandHandlerTests() { _payGate.CanManageApiKeys(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success())); - _handler = new RevokeApiKeyCommandHandler(_apiKeyRepo, _payGate, _unitOfWork); + _handler = new RevokeApiKeyCommandHandler(_apiKeyRepo, _payGate, _unitOfWork, _cache); } [Fact] diff --git a/tests/Orbit.Application.Tests/Commands/ChecklistTemplates/CreateChecklistTemplateCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/ChecklistTemplates/CreateChecklistTemplateCommandHandlerTests.cs index 57efbc57..24fb5e35 100644 --- a/tests/Orbit.Application.Tests/Commands/ChecklistTemplates/CreateChecklistTemplateCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/ChecklistTemplates/CreateChecklistTemplateCommandHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.ChecklistTemplates.Commands; using Orbit.Domain.Entities; @@ -10,13 +11,14 @@ public class CreateChecklistTemplateCommandHandlerTests { private readonly IGenericRepository _repo = Substitute.For>(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly CreateChecklistTemplateCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); public CreateChecklistTemplateCommandHandlerTests() { - _handler = new CreateChecklistTemplateCommandHandler(_repo, _unitOfWork); + _handler = new CreateChecklistTemplateCommandHandler(_repo, _unitOfWork, _cache); } [Fact] diff --git a/tests/Orbit.Application.Tests/Commands/ChecklistTemplates/DeleteChecklistTemplateCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/ChecklistTemplates/DeleteChecklistTemplateCommandHandlerTests.cs index 6c75ba1b..6e5e22bf 100644 --- a/tests/Orbit.Application.Tests/Commands/ChecklistTemplates/DeleteChecklistTemplateCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/ChecklistTemplates/DeleteChecklistTemplateCommandHandlerTests.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.ChecklistTemplates.Commands; using Orbit.Domain.Entities; @@ -11,13 +12,14 @@ public class DeleteChecklistTemplateCommandHandlerTests { private readonly IGenericRepository _repo = Substitute.For>(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly DeleteChecklistTemplateCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); public DeleteChecklistTemplateCommandHandlerTests() { - _handler = new DeleteChecklistTemplateCommandHandler(_repo, _unitOfWork); + _handler = new DeleteChecklistTemplateCommandHandler(_repo, _unitOfWork, _cache); } [Fact] diff --git a/tests/Orbit.Application.Tests/Commands/Goals/CreateGoalCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/CreateGoalCommandHandlerTests.cs index 934ff724..96ba72e3 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/CreateGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/CreateGoalCommandHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -17,6 +18,7 @@ public class CreateGoalCommandHandlerTests private readonly IUserDateService _userDateService = Substitute.For(); private readonly IGamificationService _gamificationService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly CreateGoalCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -25,7 +27,7 @@ public class CreateGoalCommandHandlerTests public CreateGoalCommandHandlerTests() { _handler = new CreateGoalCommandHandler( - _goalRepo, _payGate, _userDateService, _gamificationService, _unitOfWork, + _goalRepo, _payGate, _userDateService, _gamificationService, _unitOfWork, _cache, Substitute.For>()); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) diff --git a/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs index e4d799b9..0f4743c7 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/DeleteGoalCommandHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.Common; using Orbit.Application.Goals.Commands; @@ -14,6 +15,7 @@ public class DeleteGoalCommandHandlerTests private readonly IGenericRepository _goalRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly DeleteGoalCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -21,7 +23,7 @@ public class DeleteGoalCommandHandlerTests public DeleteGoalCommandHandlerTests() { - _handler = new DeleteGoalCommandHandler(_goalRepo, _payGate, _unitOfWork); + _handler = new DeleteGoalCommandHandler(_goalRepo, _payGate, _unitOfWork, _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 2e98a61a..ce767416 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/LinkHabitsToGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/LinkHabitsToGoalCommandHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.Common; using Orbit.Application.Goals.Commands; @@ -16,6 +17,7 @@ public class LinkHabitsToGoalCommandHandlerTests private readonly IGenericRepository _habitRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly LinkHabitsToGoalCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -23,7 +25,7 @@ public class LinkHabitsToGoalCommandHandlerTests public LinkHabitsToGoalCommandHandlerTests() { - _handler = new LinkHabitsToGoalCommandHandler(_goalRepo, _habitRepo, _payGate, _unitOfWork); + _handler = new LinkHabitsToGoalCommandHandler(_goalRepo, _habitRepo, _payGate, _unitOfWork, _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 095fa63d..6202396f 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/ReorderGoalsCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/ReorderGoalsCommandHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.Common; using Orbit.Application.Goals.Commands; @@ -14,13 +15,14 @@ public class ReorderGoalsCommandHandlerTests private readonly IGenericRepository _goalRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly ReorderGoalsCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); public ReorderGoalsCommandHandlerTests() { - _handler = new ReorderGoalsCommandHandler(_goalRepo, _payGate, _unitOfWork); + _handler = new ReorderGoalsCommandHandler(_goalRepo, _payGate, _unitOfWork, _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 94958787..7be78dcc 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/RestoreGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/RestoreGoalCommandHandlerTests.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.Common; using Orbit.Application.Goals.Commands; @@ -14,6 +15,7 @@ public class RestoreGoalCommandHandlerTests private readonly IGenericRepository _goalRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly RestoreGoalCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -21,7 +23,7 @@ public class RestoreGoalCommandHandlerTests public RestoreGoalCommandHandlerTests() { - _handler = new RestoreGoalCommandHandler(_goalRepo, _payGate, _unitOfWork); + _handler = new RestoreGoalCommandHandler(_goalRepo, _payGate, _unitOfWork, _cache); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); } diff --git a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalCommandHandlerTests.cs index 52b6b554..8b34f590 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalCommandHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -20,6 +21,7 @@ public class UpdateGoalCommandHandlerTests private readonly IUserDateService _userDateService = Substitute.For(); private readonly IGamificationService _gamificationService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly UpdateGoalCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -29,7 +31,7 @@ public class UpdateGoalCommandHandlerTests public UpdateGoalCommandHandlerTests() { _handler = new UpdateGoalCommandHandler( - _goalRepo, _progressLogRepo, _payGate, _userDateService, _gamificationService, _unitOfWork, + _goalRepo, _progressLogRepo, _payGate, _userDateService, _gamificationService, _unitOfWork, _cache, Substitute.For>()); _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 ed2cbe58..55478b27 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalProgressCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalProgressCommandHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using NSubstitute; using Orbit.Application.Common; @@ -17,6 +18,7 @@ public class UpdateGoalProgressCommandHandlerTests private readonly IPayGateService _payGate = Substitute.For(); private readonly IGamificationService _gamificationService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly UpdateGoalProgressCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -25,7 +27,7 @@ public class UpdateGoalProgressCommandHandlerTests public UpdateGoalProgressCommandHandlerTests() { _handler = new UpdateGoalProgressCommandHandler( - _goalRepo, _progressLogRepo, _payGate, _gamificationService, _unitOfWork, + _goalRepo, _progressLogRepo, _payGate, _gamificationService, _unitOfWork, _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 0739ec32..7929d8e5 100644 --- a/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalStatusCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Goals/UpdateGoalStatusCommandHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -18,6 +19,7 @@ public class UpdateGoalStatusCommandHandlerTests private readonly IPayGateService _payGate = Substitute.For(); private readonly IGamificationService _gamificationService = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly UpdateGoalStatusCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -26,7 +28,7 @@ public class UpdateGoalStatusCommandHandlerTests public UpdateGoalStatusCommandHandlerTests() { _handler = new UpdateGoalStatusCommandHandler( - _goalRepo, _payGate, _gamificationService, _unitOfWork, + _goalRepo, _payGate, _gamificationService, _unitOfWork, _cache, Substitute.For>()); _payGate.CanAccessGoals(Arg.Any(), Arg.Any()) .Returns(Result.Success()); diff --git a/tests/Orbit.Application.Tests/Commands/Tags/RestoreTagCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Tags/RestoreTagCommandHandlerTests.cs index 07254075..36b8fce6 100644 --- a/tests/Orbit.Application.Tests/Commands/Tags/RestoreTagCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Tags/RestoreTagCommandHandlerTests.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.Tags.Commands; using Orbit.Domain.Entities; @@ -11,13 +12,14 @@ public class RestoreTagCommandHandlerTests { private readonly IGenericRepository _tagRepo = Substitute.For>(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly RestoreTagCommandHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); public RestoreTagCommandHandlerTests() { - _handler = new RestoreTagCommandHandler(_tagRepo, _unitOfWork); + _handler = new RestoreTagCommandHandler(_tagRepo, _unitOfWork, _cache); } private void SetupTags(params Tag[] tags) diff --git a/tests/Orbit.Application.Tests/Commands/Tags/TagCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Tags/TagCommandHandlerTests.cs index 5e3c858d..5a899eb7 100644 --- a/tests/Orbit.Application.Tests/Commands/Tags/TagCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Tags/TagCommandHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.Tags.Commands; using Orbit.Domain.Entities; @@ -24,7 +25,7 @@ public async Task CreateTag_Valid_CreatesAndSaves() _tagRepo.AnyAsync(Arg.Any>>(), Arg.Any()) .Returns(false); - var handler = new CreateTagCommandHandler(_tagRepo, _unitOfWork); + var handler = new CreateTagCommandHandler(_tagRepo, _unitOfWork, new MemoryCache(new MemoryCacheOptions())); var command = new CreateTagCommand(UserId, "Fitness", "#ff0000"); var result = await handler.Handle(command, CancellationToken.None); @@ -43,7 +44,7 @@ public async Task CreateTag_DuplicateName_ReturnsFailure() _tagRepo.AnyAsync(Arg.Any>>(), Arg.Any()) .Returns(true); - var handler = new CreateTagCommandHandler(_tagRepo, _unitOfWork); + var handler = new CreateTagCommandHandler(_tagRepo, _unitOfWork, new MemoryCache(new MemoryCacheOptions())); var command = new CreateTagCommand(UserId, "Fitness", "#ff0000"); var result = await handler.Handle(command, CancellationToken.None); @@ -64,7 +65,7 @@ public async Task UpdateTag_Valid_UpdatesAndSaves() _tagRepo.FindAsync(Arg.Any>>(), Arg.Any()) .Returns(new List()); - var handler = new UpdateTagCommandHandler(_tagRepo, _unitOfWork); + var handler = new UpdateTagCommandHandler(_tagRepo, _unitOfWork, new MemoryCache(new MemoryCacheOptions())); var command = new UpdateTagCommand(UserId, tag.Id, "New Name", "#ffffff"); var result = await handler.Handle(command, CancellationToken.None); @@ -84,7 +85,7 @@ public async Task UpdateTag_NotFound_ReturnsFailure() Arg.Any()) .Returns((Tag?)null); - var handler = new UpdateTagCommandHandler(_tagRepo, _unitOfWork); + var handler = new UpdateTagCommandHandler(_tagRepo, _unitOfWork, new MemoryCache(new MemoryCacheOptions())); var command = new UpdateTagCommand(UserId, Guid.NewGuid(), "Name", "#fff"); var result = await handler.Handle(command, CancellationToken.None); @@ -103,7 +104,7 @@ public async Task DeleteTag_Valid_RemovesAndSaves() Arg.Any()) .Returns(tag); - var handler = new DeleteTagCommandHandler(_tagRepo, _unitOfWork); + var handler = new DeleteTagCommandHandler(_tagRepo, _unitOfWork, new MemoryCache(new MemoryCacheOptions())); var command = new DeleteTagCommand(UserId, tag.Id); var result = await handler.Handle(command, CancellationToken.None); @@ -122,7 +123,7 @@ public async Task DeleteTag_NotFound_ReturnsFailure() Arg.Any()) .Returns((Tag?)null); - var handler = new DeleteTagCommandHandler(_tagRepo, _unitOfWork); + var handler = new DeleteTagCommandHandler(_tagRepo, _unitOfWork, new MemoryCache(new MemoryCacheOptions())); var command = new DeleteTagCommand(UserId, Guid.NewGuid()); var result = await handler.Handle(command, CancellationToken.None); diff --git a/tests/Orbit.Application.Tests/Commands/UserFacts/UserFactCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/UserFacts/UserFactCommandHandlerTests.cs index e4e914ef..d8b2068f 100644 --- a/tests/Orbit.Application.Tests/Commands/UserFacts/UserFactCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/UserFacts/UserFactCommandHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.UserFacts.Commands; using Orbit.Domain.Common; @@ -32,7 +33,7 @@ public async Task DeleteFact_Valid_SoftDeletesAndSaves() Arg.Any()) .Returns(fact); - var handler = new DeleteUserFactCommandHandler(_factRepo, _payGate, _unitOfWork); + var handler = new DeleteUserFactCommandHandler(_factRepo, _payGate, _unitOfWork, new MemoryCache(new MemoryCacheOptions())); var command = new DeleteUserFactCommand(UserId, fact.Id); var result = await handler.Handle(command, CancellationToken.None); @@ -53,7 +54,7 @@ public async Task BulkDeleteFacts_Valid_SoftDeletesAllAndSaves() Arg.Any()) .Returns(new List { fact1, fact2 }.AsReadOnly()); - var handler = new BulkDeleteUserFactsCommandHandler(_factRepo, _payGate, _unitOfWork); + var handler = new BulkDeleteUserFactsCommandHandler(_factRepo, _payGate, _unitOfWork, new MemoryCache(new MemoryCacheOptions())); var command = new BulkDeleteUserFactsCommand(UserId, new List { fact1.Id, fact2.Id }); var result = await handler.Handle(command, CancellationToken.None); diff --git a/tests/Orbit.Application.Tests/Queries/ApiKeys/GetApiKeysQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/ApiKeys/GetApiKeysQueryHandlerTests.cs index f9e32ce3..48823cf3 100644 --- a/tests/Orbit.Application.Tests/Queries/ApiKeys/GetApiKeysQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/ApiKeys/GetApiKeysQueryHandlerTests.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.ApiKeys.Queries; using Orbit.Domain.Common; @@ -12,6 +13,7 @@ public class GetApiKeysQueryHandlerTests { private readonly IGenericRepository _apiKeyRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly GetApiKeysQueryHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -20,7 +22,7 @@ public GetApiKeysQueryHandlerTests() { _payGate.CanReadApiKeys(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success())); - _handler = new GetApiKeysQueryHandler(_apiKeyRepo, _payGate); + _handler = new GetApiKeysQueryHandler(_apiKeyRepo, _payGate, _cache); } [Fact] diff --git a/tests/Orbit.Application.Tests/Queries/ChecklistTemplates/GetChecklistTemplatesQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/ChecklistTemplates/GetChecklistTemplatesQueryHandlerTests.cs index 3ece49b5..48c4da0e 100644 --- a/tests/Orbit.Application.Tests/Queries/ChecklistTemplates/GetChecklistTemplatesQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/ChecklistTemplates/GetChecklistTemplatesQueryHandlerTests.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.ChecklistTemplates.Queries; using Orbit.Domain.Entities; @@ -10,13 +11,14 @@ namespace Orbit.Application.Tests.Queries.ChecklistTemplates; public class GetChecklistTemplatesQueryHandlerTests { private readonly IGenericRepository _repo = Substitute.For>(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly GetChecklistTemplatesQueryHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); public GetChecklistTemplatesQueryHandlerTests() { - _handler = new GetChecklistTemplatesQueryHandler(_repo); + _handler = new GetChecklistTemplatesQueryHandler(_repo, _cache); } [Fact] diff --git a/tests/Orbit.Application.Tests/Queries/Tags/GetTagsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Tags/GetTagsQueryHandlerTests.cs index 77cbf879..4cfd276e 100644 --- a/tests/Orbit.Application.Tests/Queries/Tags/GetTagsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Tags/GetTagsQueryHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.Tags.Queries; using Orbit.Domain.Entities; @@ -10,13 +11,14 @@ namespace Orbit.Application.Tests.Queries.Tags; public class GetTagsQueryHandlerTests { private readonly IGenericRepository _tagRepo = Substitute.For>(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly GetTagsQueryHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); public GetTagsQueryHandlerTests() { - _handler = new GetTagsQueryHandler(_tagRepo); + _handler = new GetTagsQueryHandler(_tagRepo, _cache); } [Fact] diff --git a/tests/Orbit.Application.Tests/Queries/UserFacts/GetUserFactsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/UserFacts/GetUserFactsQueryHandlerTests.cs index 4e946e04..f0b0bd5c 100644 --- a/tests/Orbit.Application.Tests/Queries/UserFacts/GetUserFactsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/UserFacts/GetUserFactsQueryHandlerTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using NSubstitute; using Orbit.Application.UserFacts.Queries; using Orbit.Domain.Common; @@ -12,6 +13,7 @@ public class GetUserFactsQueryHandlerTests { private readonly IGenericRepository _userFactRepo = Substitute.For>(); private readonly IPayGateService _payGate = Substitute.For(); + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly GetUserFactsQueryHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -20,7 +22,7 @@ public GetUserFactsQueryHandlerTests() { _payGate.CanReadUserFacts(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Result.Success())); - _handler = new GetUserFactsQueryHandler(_userFactRepo, _payGate); + _handler = new GetUserFactsQueryHandler(_userFactRepo, _payGate, _cache); } [Fact] diff --git a/tests/Orbit.Infrastructure.Tests/Authentication/ApiKeyAuthenticationHandlerTests.cs b/tests/Orbit.Infrastructure.Tests/Authentication/ApiKeyAuthenticationHandlerTests.cs index 9539643d..f920ccde 100644 --- a/tests/Orbit.Infrastructure.Tests/Authentication/ApiKeyAuthenticationHandlerTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Authentication/ApiKeyAuthenticationHandlerTests.cs @@ -42,6 +42,7 @@ private static async Task RunHandler( services.AddSingleton(apiKeyRepo); services.AddSingleton(payGate); services.AddSingleton(unitOfWork); + services.AddMemoryCache(); var serviceProvider = services.BuildServiceProvider(); var optionsMonitor = Substitute.For>(); diff --git a/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRegistryTests.cs b/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRegistryTests.cs index cc10f320..772a5fba 100644 --- a/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRegistryTests.cs +++ b/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRegistryTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -74,7 +75,7 @@ private static List BuildAll() => new SyncCleanupService(ScopeFactory(), NullLogger.Instance), new PlayNotificationCleanupService(ScopeFactory(), NullLogger.Instance), new CalendarAutoSyncService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration, TimeProvider.System), - new OpenAiBatchPollerService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), + new OpenAiBatchPollerService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration, new MemoryCache(new MemoryCacheOptions())), ]; private static IServiceScopeFactory ScopeFactory() => Substitute.For(); diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs index 7c7700f4..2edf77ef 100644 --- a/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Persistence/ConcurrencyRetryTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Orbit.Application.Common; @@ -275,6 +276,7 @@ private static UpdateGoalProgressCommandHandler CreateGoalProgressHandler(OrbitD PassingGoalGate(), Substitute.For(), new UnitOfWork(context), + new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); private static OrbitDbContext CreateContext(string dbName, ISaveChangesInterceptor? interceptor = null) diff --git a/tests/Orbit.Infrastructure.Tests/Services/OpenAiBatchPollerServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/OpenAiBatchPollerServiceTests.cs index efa7b971..fee1cfcc 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/OpenAiBatchPollerServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/OpenAiBatchPollerServiceTests.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using System.Text.Json; using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -205,7 +206,7 @@ public OpenAiBatchPollerService Service var scopeFactory = provider.GetRequiredService(); return new OpenAiBatchPollerService( scopeFactory, NullLogger.Instance, - new ConfigurationBuilder().Build()); + new ConfigurationBuilder().Build(), new MemoryCache(new MemoryCacheOptions())); } } }