From 563189fca6f417d62954697be481a2b66fdbdf8b Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Sat, 18 Oct 2025 21:51:08 +0000 Subject: [PATCH 1/2] Replace static time access with injected TimeProvider for testability Co-authored-by: Thomas Jespersen --- .agent/rules/backend/backend.md | 3 ++- .claude/rules/backend/backend.md | 3 ++- .cursor/rules/backend/backend.mdc | 3 ++- .github/copilot/rules/backend/backend.md | 3 ++- .windsurf/rules/backend/backend.md | 3 ++- .../AuthenticationCookieMiddleware.cs | 8 ++++---- .../Database/AccountManagementDbContext.cs | 4 ++-- .../Authentication/Commands/CompleteLogin.cs | 3 ++- .../Authentication/Commands/SwitchTenant.cs | 3 ++- .../Commands/CompleteEmailConfirmation.cs | 7 ++++--- .../Commands/ResendEmailConfirmationCode.cs | 5 +++-- .../Commands/StartEmailConfirmation.cs | 5 +++-- .../Domain/EmailConfirmation.cs | 12 +++++------ .../Users/Commands/DeclineInvitation.cs | 5 +++-- .../Features/Users/Domain/UserRepository.cs | 4 ++-- .../Authentication/CompleteLoginTests.cs | 12 +++++------ .../Tests/Authentication/StartLoginTests.cs | 4 ++-- .../Tests/Authentication/SwitchTenantTests.cs | 20 +++++++++---------- .../Tests/EndpointBaseTest.cs | 2 ++ .../Tests/Signups/CompleteSignupTests.cs | 4 ++-- .../Tests/Signups/StartSignupTests.cs | 4 ++-- .../Tests/Tenants/DeleteTenantTests.cs | 2 +- .../Tests/Tenants/GetTenantsForUserTests.cs | 16 +++++++-------- .../Tests/Users/BulkDeleteUsersTests.cs | 2 +- .../Tests/Users/DeclineInvitationTests.cs | 12 +++++------ .../Tests/Users/DeleteUserTests.cs | 6 +++--- .../Tests/Users/GetUserByIdTests.cs | 2 +- .../Tests/Users/GetUsersTests.cs | 4 ++-- .../Core/Database/BackOfficeDbContext.cs | 4 ++-- .../back-office/Tests/EndpointBaseTest.cs | 2 ++ .../TokenGeneration/AccessTokenGenerator.cs | 6 ++++-- .../TokenGeneration/RefreshTokenGenerator.cs | 5 +++-- .../SecurityTokenDescriptorExtensions.cs | 4 +++- .../SharedDependencyConfiguration.cs | 8 ++++++++ .../SharedInfrastructureConfiguration.cs | 14 ++++++++----- .../EntityFramework/ITimeProviderSource.cs | 9 +++++++++ .../EntityFramework/SharedKernelDbContext.cs | 6 ++++-- .../UpdateAuditableEntitiesInterceptor.cs | 6 +++++- .../BlobStorage/BlobStorageClient.cs | 15 +++++++------- .../MapStronglyTypedStringTests.cs | 2 +- .../SaveChangesInterceptorTests.cs | 2 +- .../EntityFramework/UseStringForEnumsTests.cs | 2 +- .../Tests/Persistence/RepositoryTests.cs | 2 +- .../Persistence/SqliteConnectionExtensions.cs | 4 ++++ .../Tests/Persistence/UnitOfWorkTests.cs | 2 +- .../SqliteInMemoryDbContextFactory.cs | 6 ++++-- .../Tests/TestEntities/TestDbContext.cs | 4 ++-- 47 files changed, 159 insertions(+), 105 deletions(-) create mode 100644 application/shared-kernel/SharedKernel/EntityFramework/ITimeProviderSource.cs diff --git a/.agent/rules/backend/backend.md b/.agent/rules/backend/backend.md index 219a1bb03..530637477 100644 --- a/.agent/rules/backend/backend.md +++ b/.agent/rules/backend/backend.md @@ -61,7 +61,8 @@ Guidelines for C# backend development, including code style, naming, exceptions, - Use `user?.IsActive == true` over `user != null && user.IsActive == true` - Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions - Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure -- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()` +- Inject `TimeProvider` into services and handlers, use `timeProvider.GetUtcNow()` instead of `DateTimeOffset.UtcNow` +- Pass `DateTimeOffset` values (not `TimeProvider`) to domain methods and aggregates to maintain clean boundaries (e.g., `entity.HasExpired(timeProvider.GetUtcNow())`) - Naming rules: - Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`) - Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`) diff --git a/.claude/rules/backend/backend.md b/.claude/rules/backend/backend.md index de1cda830..24ad4f545 100644 --- a/.claude/rules/backend/backend.md +++ b/.claude/rules/backend/backend.md @@ -61,7 +61,8 @@ Guidelines for C# backend development, including code style, naming, exceptions, - Use `user?.IsActive == true` over `user != null && user.IsActive == true` - Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions - Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure -- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()` +- Inject `TimeProvider` into services and handlers, use `timeProvider.GetUtcNow()` instead of `DateTimeOffset.UtcNow` +- Pass `DateTimeOffset` values (not `TimeProvider`) to domain methods and aggregates to maintain clean boundaries (e.g., `entity.HasExpired(timeProvider.GetUtcNow())`) - Naming rules: - Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`) - Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`) diff --git a/.cursor/rules/backend/backend.mdc b/.cursor/rules/backend/backend.mdc index 39f99adf0..393a5179b 100644 --- a/.cursor/rules/backend/backend.mdc +++ b/.cursor/rules/backend/backend.mdc @@ -61,7 +61,8 @@ Guidelines for C# backend development, including code style, naming, exceptions, - Use `user?.IsActive == true` over `user != null && user.IsActive == true` - Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions - Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure -- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()` +- Inject `TimeProvider` into services and handlers, use `timeProvider.GetUtcNow()` instead of `DateTimeOffset.UtcNow` +- Pass `DateTimeOffset` values (not `TimeProvider`) to domain methods and aggregates to maintain clean boundaries (e.g., `entity.HasExpired(timeProvider.GetUtcNow())`) - Naming rules: - Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`) - Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`) diff --git a/.github/copilot/rules/backend/backend.md b/.github/copilot/rules/backend/backend.md index 40c2a7fa1..1f6290e6f 100644 --- a/.github/copilot/rules/backend/backend.md +++ b/.github/copilot/rules/backend/backend.md @@ -56,7 +56,8 @@ Guidelines for C# backend development, including code style, naming, exceptions, - Use `user?.IsActive == true` over `user != null && user.IsActive == true` - Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions - Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure -- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()` +- Inject `TimeProvider` into services and handlers, use `timeProvider.GetUtcNow()` instead of `DateTimeOffset.UtcNow` +- Pass `DateTimeOffset` values (not `TimeProvider`) to domain methods and aggregates to maintain clean boundaries (e.g., `entity.HasExpired(timeProvider.GetUtcNow())`) - Naming rules: - Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`) - Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`) diff --git a/.windsurf/rules/backend/backend.md b/.windsurf/rules/backend/backend.md index 735b3ce99..1224b77a4 100644 --- a/.windsurf/rules/backend/backend.md +++ b/.windsurf/rules/backend/backend.md @@ -62,7 +62,8 @@ Guidelines for C# backend development, including code style, naming, exceptions, - Use `user?.IsActive == true` over `user != null && user.IsActive == true` - Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions - Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure -- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()` +- Inject `TimeProvider` into services and handlers, use `timeProvider.GetUtcNow()` instead of `DateTimeOffset.UtcNow` +- Pass `DateTimeOffset` values (not `TimeProvider`) to domain methods and aggregates to maintain clean boundaries (e.g., `entity.HasExpired(timeProvider.GetUtcNow())`) - Naming rules: - Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`) - Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`) diff --git a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs index 80a5064f0..a26a77263 100644 --- a/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs +++ b/application/AppGateway/Middleware/AuthenticationCookieMiddleware.cs @@ -10,9 +10,9 @@ namespace PlatformPlatform.AppGateway.Middleware; public class AuthenticationCookieMiddleware( ITokenSigningClient tokenSigningClient, IHttpClientFactory httpClientFactory, + TimeProvider timeProvider, ILogger logger -) - : IMiddleware +) : IMiddleware { private const string? RefreshAuthenticationTokensEndpoint = "/internal-api/account-management/authentication/refresh-authentication-tokens"; @@ -51,9 +51,9 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http try { - if (accessToken is null || ExtractExpirationFromToken(accessToken) < TimeProvider.System.GetUtcNow()) + if (accessToken is null || ExtractExpirationFromToken(accessToken) < timeProvider.GetUtcNow()) { - if (ExtractExpirationFromToken(refreshToken) < TimeProvider.System.GetUtcNow()) + if (ExtractExpirationFromToken(refreshToken) < timeProvider.GetUtcNow()) { context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName); context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName); diff --git a/application/account-management/Core/Database/AccountManagementDbContext.cs b/application/account-management/Core/Database/AccountManagementDbContext.cs index 5141ac3c9..c40287feb 100644 --- a/application/account-management/Core/Database/AccountManagementDbContext.cs +++ b/application/account-management/Core/Database/AccountManagementDbContext.cs @@ -4,5 +4,5 @@ namespace PlatformPlatform.AccountManagement.Database; -public sealed class AccountManagementDbContext(DbContextOptions options, IExecutionContext executionContext) - : SharedKernelDbContext(options, executionContext); +public sealed class AccountManagementDbContext(DbContextOptions options, IExecutionContext executionContext, TimeProvider timeProvider) + : SharedKernelDbContext(options, executionContext, timeProvider); diff --git a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs b/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs index c52b0b7bf..ac5072000 100644 --- a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs +++ b/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs @@ -27,6 +27,7 @@ public sealed class CompleteLoginHandler( AvatarUpdater avatarUpdater, GravatarClient gravatarClient, ITelemetryEventsCollector events, + TimeProvider timeProvider, ILogger logger ) : IRequestHandler { @@ -98,7 +99,7 @@ private void CompleteUserInvite(User user) { user.ConfirmEmail(); userRepository.Update(user); - var inviteAcceptedTimeInMinutes = (int)(TimeProvider.System.GetUtcNow() - user.CreatedAt).TotalMinutes; + var inviteAcceptedTimeInMinutes = (int)(timeProvider.GetUtcNow() - user.CreatedAt).TotalMinutes; events.CollectEvent(new UserInviteAccepted(user.Id, inviteAcceptedTimeInMinutes)); } } diff --git a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs index aee7deb6d..81b0c0819 100644 --- a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs +++ b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs @@ -23,6 +23,7 @@ public sealed class SwitchTenantHandler( IBlobStorageClient blobStorageClient, IExecutionContext executionContext, ITelemetryEventsCollector events, + TimeProvider timeProvider, ILogger logger ) : IRequestHandler { @@ -88,7 +89,7 @@ private async Task CopyProfileDataFromCurrentUser(User targetUser, CancellationT userRepository.Update(targetUser); // Calculate how long it took to accept the invitation - var inviteAcceptedTimeInMinutes = (int)(DateTimeOffset.UtcNow - targetUser.CreatedAt).TotalMinutes; + var inviteAcceptedTimeInMinutes = (int)(timeProvider.GetUtcNow() - targetUser.CreatedAt).TotalMinutes; events.CollectEvent(new UserInviteAccepted(targetUser.Id, inviteAcceptedTimeInMinutes)); } } diff --git a/application/account-management/Core/Features/EmailConfirmations/Commands/CompleteEmailConfirmation.cs b/application/account-management/Core/Features/EmailConfirmations/Commands/CompleteEmailConfirmation.cs index 636bbc159..f9f779f2b 100644 --- a/application/account-management/Core/Features/EmailConfirmations/Commands/CompleteEmailConfirmation.cs +++ b/application/account-management/Core/Features/EmailConfirmations/Commands/CompleteEmailConfirmation.cs @@ -17,6 +17,7 @@ public sealed class CompleteEmailConfirmationHandler( IEmailConfirmationRepository emailConfirmationRepository, OneTimePasswordHelper oneTimePasswordHelper, ITelemetryEventsCollector events, + TimeProvider timeProvider, ILogger logger ) : IRequestHandler> { @@ -51,14 +52,14 @@ public async Task> Handle(CompleteEmai return Result.BadRequest("The code is wrong or no longer valid.", true); } - var confirmationTimeInSeconds = (int)(TimeProvider.System.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds; - if (emailConfirmation.HasExpired()) + var confirmationTimeInSeconds = (int)(timeProvider.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds; + if (emailConfirmation.HasExpired(timeProvider.GetUtcNow())) { events.CollectEvent(new EmailConfirmationExpired(emailConfirmation.Id, emailConfirmation.Type, confirmationTimeInSeconds)); return Result.BadRequest("The code is no longer valid, please request a new code.", true); } - emailConfirmation.MarkAsCompleted(); + emailConfirmation.MarkAsCompleted(timeProvider.GetUtcNow()); emailConfirmationRepository.Update(emailConfirmation); return new CompleteEmailConfirmationResponse(emailConfirmation.Email, confirmationTimeInSeconds); diff --git a/application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs b/application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs index 94f395f6b..31ae6ee6f 100644 --- a/application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs +++ b/application/account-management/Core/Features/EmailConfirmations/Commands/ResendEmailConfirmationCode.cs @@ -23,6 +23,7 @@ public sealed class ResendEmailConfirmationCodeHandler( IEmailClient emailClient, IPasswordHasher passwordHasher, ITelemetryEventsCollector events, + TimeProvider timeProvider, ILogger logger ) : IRequestHandler> { @@ -45,10 +46,10 @@ public async Task> Handle(ResendEmai var oneTimePassword = OneTimePasswordHelper.GenerateOneTimePassword(6); var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword); - emailConfirmation.UpdateVerificationCode(oneTimePasswordHash); + emailConfirmation.UpdateVerificationCode(oneTimePasswordHash, timeProvider.GetUtcNow()); emailConfirmationRepository.Update(emailConfirmation); - var secondsSinceSignupStarted = (TimeProvider.System.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds; + var secondsSinceSignupStarted = (timeProvider.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds; events.CollectEvent(new EmailConfirmationResend((int)secondsSinceSignupStarted)); await emailClient.SendAsync(emailConfirmation.Email, "Your verification code (resend)", diff --git a/application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs b/application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs index f3c7b2018..167ea385c 100644 --- a/application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs +++ b/application/account-management/Core/Features/EmailConfirmations/Commands/StartEmailConfirmation.cs @@ -29,7 +29,8 @@ public StartEmailConfirmationValidator() public sealed class StartEmailConfirmationHandler( IEmailConfirmationRepository emailConfirmationRepository, IEmailClient emailClient, - IPasswordHasher passwordHasher + IPasswordHasher passwordHasher, + TimeProvider timeProvider ) : IRequestHandler> { public async Task> Handle(StartEmailConfirmationCommand command, CancellationToken cancellationToken) @@ -37,7 +38,7 @@ public async Task> Handle(StartEmailConfi var existingConfirmations = emailConfirmationRepository.GetByEmail(command.Email).ToArray(); var lockoutMinutes = command.Type == EmailConfirmationType.Signup ? -60 : -15; - if (existingConfirmations.Count(r => r.CreatedAt > TimeProvider.System.GetUtcNow().AddMinutes(lockoutMinutes)) >= 3) + if (existingConfirmations.Count(r => r.CreatedAt > timeProvider.GetUtcNow().AddMinutes(lockoutMinutes)) >= 3) { return Result.TooManyRequests("Too many attempts to confirm this email address. Please try again later."); } diff --git a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmation.cs b/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmation.cs index d3e8472ce..9739cdd50 100644 --- a/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmation.cs +++ b/application/account-management/Core/Features/EmailConfirmations/Domain/EmailConfirmation.cs @@ -34,9 +34,9 @@ private EmailConfirmation(string email, EmailConfirmationType type, string oneTi public bool Completed { get; private set; } - public bool HasExpired() + public bool HasExpired(DateTimeOffset now) { - return ValidUntil < TimeProvider.System.GetUtcNow(); + return ValidUntil < now; } public static EmailConfirmation Create(string email, string oneTimePasswordHash, EmailConfirmationType type) @@ -49,9 +49,9 @@ public void RegisterInvalidPasswordAttempt() RetryCount++; } - public void MarkAsCompleted() + public void MarkAsCompleted(DateTimeOffset now) { - if (HasExpired() || RetryCount >= MaxAttempts) + if (HasExpired(now) || RetryCount >= MaxAttempts) { throw new UnreachableException("This email confirmation has expired."); } @@ -61,7 +61,7 @@ public void MarkAsCompleted() Completed = true; } - public void UpdateVerificationCode(string oneTimePasswordHash) + public void UpdateVerificationCode(string oneTimePasswordHash, DateTimeOffset now) { if (Completed) { @@ -73,7 +73,7 @@ public void UpdateVerificationCode(string oneTimePasswordHash) throw new UnreachableException("Cannot regenerate verification code for email confirmation that has been resent too many times."); } - ValidUntil = TimeProvider.System.GetUtcNow().AddSeconds(ValidForSeconds); + ValidUntil = now.AddSeconds(ValidForSeconds); OneTimePasswordHash = oneTimePasswordHash; ResendCount++; } diff --git a/application/account-management/Core/Features/Users/Commands/DeclineInvitation.cs b/application/account-management/Core/Features/Users/Commands/DeclineInvitation.cs index 2027be85c..8640abe24 100644 --- a/application/account-management/Core/Features/Users/Commands/DeclineInvitation.cs +++ b/application/account-management/Core/Features/Users/Commands/DeclineInvitation.cs @@ -13,7 +13,8 @@ public sealed record DeclineInvitationCommand(TenantId TenantId) : ICommand, IRe public sealed class DeclineInvitationHandler( IUserRepository userRepository, IExecutionContext executionContext, - ITelemetryEventsCollector events + ITelemetryEventsCollector events, + TimeProvider timeProvider ) : IRequestHandler { public async Task Handle(DeclineInvitationCommand command, CancellationToken cancellationToken) @@ -34,7 +35,7 @@ public async Task Handle(DeclineInvitationCommand command, CancellationT } // Calculate how long the invitation existed - var inviteExistedTimeInMinutes = (int)(TimeProvider.System.GetUtcNow() - user.CreatedAt).TotalMinutes; + var inviteExistedTimeInMinutes = (int)(timeProvider.GetUtcNow() - user.CreatedAt).TotalMinutes; // Delete the user to decline the invitation userRepository.Remove(user); diff --git a/application/account-management/Core/Features/Users/Domain/UserRepository.cs b/application/account-management/Core/Features/Users/Domain/UserRepository.cs index bd7f9227c..4bd92623b 100644 --- a/application/account-management/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account-management/Core/Features/Users/Domain/UserRepository.cs @@ -40,7 +40,7 @@ CancellationToken cancellationToken Task GetUsersByEmailUnfilteredAsync(string email, CancellationToken cancellationToken); } -internal sealed class UserRepository(AccountManagementDbContext accountManagementDbContext, IExecutionContext executionContext) +internal sealed class UserRepository(AccountManagementDbContext accountManagementDbContext, IExecutionContext executionContext, TimeProvider timeProvider) : RepositoryBase(accountManagementDbContext), IUserRepository { /// @@ -89,7 +89,7 @@ public async Task GetByIdsAsync(UserId[] ids, CancellationToken cancella public async Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserSummaryAsync(CancellationToken cancellationToken) { - var thirtyDaysAgo = TimeProvider.System.GetUtcNow().AddDays(-30); + var thirtyDaysAgo = timeProvider.GetUtcNow().AddDays(-30); var summary = await DbSet .GroupBy(_ => 1) // Group all records into a single group to calculate multiple COUNT aggregates in one query diff --git a/application/account-management/Tests/Authentication/CompleteLoginTests.cs b/application/account-management/Tests/Authentication/CompleteLoginTests.cs index b789a06fd..d213eda6f 100644 --- a/application/account-management/Tests/Authentication/CompleteLoginTests.cs +++ b/application/account-management/Tests/Authentication/CompleteLoginTests.cs @@ -159,7 +159,7 @@ public async Task CompleteLogin_WhenLoginExpired_ShouldReturnBadRequest() ("TenantId", DatabaseSeeder.Tenant1Owner.TenantId.ToString()), ("UserId", DatabaseSeeder.Tenant1Owner.Id.ToString()), ("Id", loginId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("EmailConfirmationId", emailConfirmationId.ToString()), ("Completed", false) @@ -167,12 +167,12 @@ public async Task CompleteLogin_WhenLoginExpired_ShouldReturnBadRequest() ); Connection.Insert("EmailConfirmations", [ ("Id", emailConfirmationId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Owner.Email), ("Type", EmailConfirmationType.Signup), ("OneTimePasswordHash", new PasswordHasher().HashPassword(this, CorrectOneTimePassword)), - ("ValidUntil", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-10)), ("RetryCount", 0), ("ResendCount", 0), ("Completed", false) @@ -236,7 +236,7 @@ public async Task CompleteLogin_WithValidPreferredTenant_ShouldLoginToPreferredT Connection.Insert("Tenants", [ ("Id", tenant2Id.Value), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", Faker.Company.CompanyName()), ("State", nameof(TenantState.Active)), @@ -247,7 +247,7 @@ public async Task CompleteLogin_WithValidPreferredTenant_ShouldLoginToPreferredT Connection.Insert("Users", [ ("TenantId", tenant2Id.Value), ("Id", user2Id.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Owner.Email), ("EmailConfirmed", true), @@ -309,7 +309,7 @@ public async Task CompleteLogin_WithPreferredTenantUserDoesNotHaveAccess_ShouldL Connection.Insert("Tenants", [ ("Id", tenant2Id.Value), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", Faker.Company.CompanyName()), ("State", nameof(TenantState.Active)), diff --git a/application/account-management/Tests/Authentication/StartLoginTests.cs b/application/account-management/Tests/Authentication/StartLoginTests.cs index 60a5addb6..c6f48034e 100644 --- a/application/account-management/Tests/Authentication/StartLoginTests.cs +++ b/application/account-management/Tests/Authentication/StartLoginTests.cs @@ -127,12 +127,12 @@ public async Task StartLogin_WhenTooManyAttempts_ShouldReturnTooManyRequests() var oneTimePasswordHash = new PasswordHasher().HashPassword(this, OneTimePasswordHelper.GenerateOneTimePassword(6)); Connection.Insert("EmailConfirmations", [ ("Id", EmailConfirmationId.NewId().ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-i)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-i)), ("ModifiedAt", null), ("Email", email.ToLower()), ("Type", nameof(EmailConfirmationType.Login)), ("OneTimePasswordHash", oneTimePasswordHash), - ("ValidUntil", TimeProvider.System.GetUtcNow().AddMinutes(-i - 1)), // All should be expired + ("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-i - 1)), // All should be expired ("RetryCount", 0), ("ResendCount", 0), ("Completed", false) diff --git a/application/account-management/Tests/Authentication/SwitchTenantTests.cs b/application/account-management/Tests/Authentication/SwitchTenantTests.cs index 10f98bfe9..83f48f349 100644 --- a/application/account-management/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account-management/Tests/Authentication/SwitchTenantTests.cs @@ -25,7 +25,7 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf Connection.Insert("Tenants", [ ("Id", tenant2Id.Value), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", tenant2Name), ("State", nameof(TenantState.Active)), @@ -36,7 +36,7 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf Connection.Insert("Users", [ ("TenantId", tenant2Id.Value), ("Id", user2Id.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Member.Email), ("EmailConfirmed", true), @@ -93,7 +93,7 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo Connection.Insert("Tenants", [ ("Id", tenant2Id.Value), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", Faker.Company.CompanyName()), ("State", nameof(TenantState.Active)), @@ -104,7 +104,7 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo Connection.Insert("Users", [ ("TenantId", tenant2Id.Value), ("Id", UserId.NewId().ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Email", Faker.Internet.UniqueEmail()), ("EmailConfirmed", true), @@ -152,7 +152,7 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() Connection.Insert("Tenants", [ ("Id", tenant2Id.Value), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", tenant2Name), ("State", nameof(TenantState.Active)), @@ -163,7 +163,7 @@ public async Task SwitchTenant_WhenUserEmailNotConfirmed_ShouldConfirmEmail() Connection.Insert("Users", [ ("TenantId", tenant2Id.Value), ("Id", user2Id.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Member.Email), ("EmailConfirmed", false), // User's email is not confirmed @@ -219,7 +219,7 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() Connection.Insert("Tenants", [ ("Id", tenant2Id.Value), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", tenant2Name), ("State", nameof(TenantState.Active)), @@ -231,7 +231,7 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() Connection.Insert("Users", [ ("TenantId", tenant2Id.Value), ("Id", user2Id.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Member.Email), ("EmailConfirmed", false), // Unconfirmed - invitation pending @@ -294,7 +294,7 @@ public async Task SwitchTenant_RapidSwitching_ShouldHandleCorrectly() Connection.Insert("Tenants", [ ("Id", tenant2Id.Value), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", Faker.Company.CompanyName()), ("State", nameof(TenantState.Active)), @@ -305,7 +305,7 @@ public async Task SwitchTenant_RapidSwitching_ShouldHandleCorrectly() Connection.Insert("Users", [ ("TenantId", tenant2Id.Value), ("Id", user2Id.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Member.Email), ("EmailConfirmed", true), diff --git a/application/account-management/Tests/EndpointBaseTest.cs b/application/account-management/Tests/EndpointBaseTest.cs index 80f6aa55e..0efe6a4f6 100644 --- a/application/account-management/Tests/EndpointBaseTest.cs +++ b/application/account-management/Tests/EndpointBaseTest.cs @@ -29,6 +29,7 @@ public abstract class EndpointBaseTest : IDisposable where TContext : protected readonly IEmailClient EmailClient; protected readonly Faker Faker = new(); protected readonly ServiceCollection Services; + protected readonly TimeProvider TimeProvider; private readonly WebApplicationFactory _webApplicationFactory; protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; @@ -42,6 +43,7 @@ protected EndpointBaseTest() ); Services = new ServiceCollection(); + TimeProvider = TimeProvider.System; Services.AddLogging(); Services.AddTransient(); diff --git a/application/account-management/Tests/Signups/CompleteSignupTests.cs b/application/account-management/Tests/Signups/CompleteSignupTests.cs index 9da97cf1e..3378de982 100644 --- a/application/account-management/Tests/Signups/CompleteSignupTests.cs +++ b/application/account-management/Tests/Signups/CompleteSignupTests.cs @@ -142,12 +142,12 @@ public async Task CompleteSignup_WhenSignupExpired_ShouldReturnBadRequest() var emailConfirmationId = EmailConfirmationId.NewId(); Connection.Insert("EmailConfirmations", [ ("Id", emailConfirmationId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", email), ("Type", EmailConfirmationType.Signup), ("OneTimePasswordHash", new PasswordHasher().HashPassword(this, CorrectOneTimePassword)), - ("ValidUntil", TimeProvider.System.GetUtcNow().AddMinutes(-5)), + ("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-5)), ("RetryCount", 0), ("ResendCount", 0), ("Completed", false) diff --git a/application/account-management/Tests/Signups/StartSignupTests.cs b/application/account-management/Tests/Signups/StartSignupTests.cs index e09a0e31b..7cb6e9978 100644 --- a/application/account-management/Tests/Signups/StartSignupTests.cs +++ b/application/account-management/Tests/Signups/StartSignupTests.cs @@ -78,12 +78,12 @@ public async Task StartSignup_WhenTooManyAttempts_ShouldReturnTooManyRequests() var oneTimePasswordHash = new PasswordHasher().HashPassword(this, OneTimePasswordHelper.GenerateOneTimePassword(6)); Connection.Insert("EmailConfirmations", [ ("Id", EmailConfirmationId.NewId().ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-i)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-i)), ("ModifiedAt", null), ("Email", email), ("Type", nameof(EmailConfirmationType.Signup)), ("OneTimePasswordHash", oneTimePasswordHash), - ("ValidUntil", TimeProvider.System.GetUtcNow().AddMinutes(-i - 1)), // All should be expired + ("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-i - 1)), // All should be expired ("RetryCount", 0), ("ResendCount", 0), ("Completed", false) diff --git a/application/account-management/Tests/Tenants/DeleteTenantTests.cs b/application/account-management/Tests/Tenants/DeleteTenantTests.cs index ba76d358b..328993c07 100644 --- a/application/account-management/Tests/Tenants/DeleteTenantTests.cs +++ b/application/account-management/Tests/Tenants/DeleteTenantTests.cs @@ -36,7 +36,7 @@ public async Task DeleteTenant_WhenTenantHasUsers_ShouldReturnBadRequest() Connection.Insert("Users", [ ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), ("Id", UserId.NewId().ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", Faker.Internet.UniqueEmail()), ("FirstName", Faker.Person.FirstName), diff --git a/application/account-management/Tests/Tenants/GetTenantsForUserTests.cs b/application/account-management/Tests/Tenants/GetTenantsForUserTests.cs index 781ae7869..393c8a0f0 100644 --- a/application/account-management/Tests/Tenants/GetTenantsForUserTests.cs +++ b/application/account-management/Tests/Tenants/GetTenantsForUserTests.cs @@ -25,7 +25,7 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants() Connection.Insert("Tenants", [ ("Id", tenant2Id.Value), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", tenant2Name), ("State", nameof(TenantState.Active)), @@ -36,7 +36,7 @@ public async Task GetTenants_UserWithMultipleTenants_ReturnsAllTenants() Connection.Insert("Users", [ ("TenantId", tenant2Id.Value), ("Id", user2Id.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Member.Email), ("EmailConfirmed", true), @@ -98,7 +98,7 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse Connection.Insert("Tenants", [ ("Id", otherTenantId.Value), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", "Other Tenant"), ("State", nameof(TenantState.Active)), @@ -109,7 +109,7 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse Connection.Insert("Users", [ ("TenantId", otherTenantId.Value), ("Id", otherUserId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Email", email), ("EmailConfirmed", true), @@ -142,7 +142,7 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers Connection.Insert("Tenants", [ ("Id", otherUserTenantId.Value), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", "Other User Tenant"), ("State", nameof(TenantState.Active)), @@ -153,7 +153,7 @@ public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsers Connection.Insert("Users", [ ("TenantId", otherUserTenantId.Value), ("Id", otherUserId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Email", otherUserEmail), ("EmailConfirmed", true), @@ -187,7 +187,7 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant() Connection.Insert("Tenants", [ ("Id", tenant2Id.Value), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", tenant2Name), ("State", nameof(TenantState.Active)), @@ -198,7 +198,7 @@ public async Task GetTenants_UserWithUnconfirmedEmail_ShowsAsNewTenant() Connection.Insert("Users", [ ("TenantId", tenant2Id.Value), ("Id", user2Id.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Member.Email), ("EmailConfirmed", false), // User has not confirmed email in this tenant diff --git a/application/account-management/Tests/Users/BulkDeleteUsersTests.cs b/application/account-management/Tests/Users/BulkDeleteUsersTests.cs index 4c5b6136f..1580125b7 100644 --- a/application/account-management/Tests/Users/BulkDeleteUsersTests.cs +++ b/application/account-management/Tests/Users/BulkDeleteUsersTests.cs @@ -27,7 +27,7 @@ public async Task BulkDeleteUsers_WhenUsersExist_ShouldDeleteUsers() Connection.Insert("Users", [ ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), ("Id", userId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", Faker.Internet.UniqueEmail()), ("FirstName", Faker.Person.FirstName), diff --git a/application/account-management/Tests/Users/DeclineInvitationTests.cs b/application/account-management/Tests/Users/DeclineInvitationTests.cs index 5178c7a4d..2148b2c29 100644 --- a/application/account-management/Tests/Users/DeclineInvitationTests.cs +++ b/application/account-management/Tests/Users/DeclineInvitationTests.cs @@ -24,7 +24,7 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol Connection.Insert("Tenants", [ ("Id", newTenantId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", Faker.Company.CompanyName()), ("State", nameof(TenantState.Trial)), @@ -35,7 +35,7 @@ public async Task DeclineInvitation_WhenValidInviteExists_ShouldDeleteUserAndCol Connection.Insert("Users", [ ("TenantId", newTenantId.ToString()), ("Id", userId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Member.Email), ("EmailConfirmed", false), @@ -94,7 +94,7 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif Connection.Insert("Tenants", [ ("Id", tenant2Id.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", Faker.Company.CompanyName()), ("State", nameof(TenantState.Trial)), @@ -104,7 +104,7 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif Connection.Insert("Tenants", [ ("Id", tenant3Id.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow()), + ("CreatedAt", TimeProvider.GetUtcNow()), ("ModifiedAt", null), ("Name", Faker.Company.CompanyName()), ("State", nameof(TenantState.Trial)), @@ -115,7 +115,7 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif Connection.Insert("Users", [ ("TenantId", tenant2Id.ToString()), ("Id", userId2.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Member.Email), ("EmailConfirmed", false), @@ -131,7 +131,7 @@ public async Task DeclineInvitation_WhenMultipleInvitesExist_ShouldDeclineSpecif Connection.Insert("Users", [ ("TenantId", tenant3Id.ToString()), ("Id", userId3.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-5)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-5)), ("ModifiedAt", null), ("Email", DatabaseSeeder.Tenant1Member.Email), ("EmailConfirmed", false), diff --git a/application/account-management/Tests/Users/DeleteUserTests.cs b/application/account-management/Tests/Users/DeleteUserTests.cs index 68c80b31e..e7aa25924 100644 --- a/application/account-management/Tests/Users/DeleteUserTests.cs +++ b/application/account-management/Tests/Users/DeleteUserTests.cs @@ -35,7 +35,7 @@ public async Task DeleteUser_WhenUserExists_ShouldDeleteUser() Connection.Insert("Users", [ ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), ("Id", userId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", Faker.Internet.UniqueEmail()), ("FirstName", Faker.Person.FirstName), @@ -77,7 +77,7 @@ public async Task DeleteUser_WhenUserHasLoginHistory_ShouldDeleteUserAndLogins() Connection.Insert("Users", [ ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), ("Id", userId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", Faker.Internet.UniqueEmail()), ("FirstName", Faker.Person.FirstName), @@ -97,7 +97,7 @@ public async Task DeleteUser_WhenUserHasLoginHistory_ShouldDeleteUserAndLogins() ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), ("Id", loginId.ToString()), ("UserId", userId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-5)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-5)), ("ModifiedAt", null), ("EmailConfirmationId", emailConfirmationId.ToString()), ("Completed", true) diff --git a/application/account-management/Tests/Users/GetUserByIdTests.cs b/application/account-management/Tests/Users/GetUserByIdTests.cs index bf7f491d5..128796455 100644 --- a/application/account-management/Tests/Users/GetUserByIdTests.cs +++ b/application/account-management/Tests/Users/GetUserByIdTests.cs @@ -20,7 +20,7 @@ public GetUserByIdTests() Connection.Insert("Users", [ ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), ("Id", _userId.ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", Faker.Internet.UniqueEmail()), ("FirstName", Faker.Name.FirstName()), diff --git a/application/account-management/Tests/Users/GetUsersTests.cs b/application/account-management/Tests/Users/GetUsersTests.cs index 882893710..01bb68bb3 100644 --- a/application/account-management/Tests/Users/GetUsersTests.cs +++ b/application/account-management/Tests/Users/GetUsersTests.cs @@ -22,7 +22,7 @@ public GetUsersTests() Connection.Insert("Users", [ ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), ("Id", UserId.NewId().ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", Email), ("FirstName", FirstName), @@ -37,7 +37,7 @@ public GetUsersTests() Connection.Insert("Users", [ ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), ("Id", UserId.NewId().ToString()), - ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), ("Email", "ada@lovelace.com"), ("FirstName", "Ada"), diff --git a/application/back-office/Core/Database/BackOfficeDbContext.cs b/application/back-office/Core/Database/BackOfficeDbContext.cs index ca5e1bc2d..9b6eeb24e 100644 --- a/application/back-office/Core/Database/BackOfficeDbContext.cs +++ b/application/back-office/Core/Database/BackOfficeDbContext.cs @@ -4,5 +4,5 @@ namespace PlatformPlatform.BackOffice.Database; -public sealed class BackOfficeDbContext(DbContextOptions options, IExecutionContext executionContext) - : SharedKernelDbContext(options, executionContext); +public sealed class BackOfficeDbContext(DbContextOptions options, IExecutionContext executionContext, TimeProvider timeProvider) + : SharedKernelDbContext(options, executionContext, timeProvider); diff --git a/application/back-office/Tests/EndpointBaseTest.cs b/application/back-office/Tests/EndpointBaseTest.cs index bd999ab0a..b98f7581b 100644 --- a/application/back-office/Tests/EndpointBaseTest.cs +++ b/application/back-office/Tests/EndpointBaseTest.cs @@ -29,6 +29,7 @@ public abstract class EndpointBaseTest : IDisposable where TContext : protected readonly IEmailClient EmailClient; protected readonly Faker Faker = new(); protected readonly ServiceCollection Services; + [UsedImplicitly] protected readonly TimeProvider TimeProvider; private readonly WebApplicationFactory _webApplicationFactory; protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; @@ -42,6 +43,7 @@ protected EndpointBaseTest() ); Services = new ServiceCollection(); + TimeProvider = TimeProvider.System; Services.AddLogging(); Services.AddTransient(); diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs index 8ec213138..ae9a3e13c 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs @@ -5,7 +5,7 @@ namespace PlatformPlatform.SharedKernel.Authentication.TokenGeneration; -public sealed class AccessTokenGenerator(ITokenSigningClient tokenSigningClient) +public sealed class AccessTokenGenerator(ITokenSigningClient tokenSigningClient, TimeProvider timeProvider) { // Access tokens should only be valid for a very short time and cannot be revoked. // For example, if a user gets a new role, the changes will not take effect until the access token expires. @@ -31,8 +31,10 @@ public string Generate(UserInfo userInfo) ) }; + var now = timeProvider.GetUtcNow(); return tokenDescriptor.GenerateToken( - TimeProvider.System.GetUtcNow().AddMinutes(ValidForMinutes).UtcDateTime, + now, + now.AddMinutes(ValidForMinutes), tokenSigningClient.Issuer, tokenSigningClient.Audience, tokenSigningClient.GetSigningCredentials() diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs index 5cbf9fec2..dfa0f4006 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/RefreshTokenGenerator.cs @@ -5,7 +5,7 @@ namespace PlatformPlatform.SharedKernel.Authentication.TokenGeneration; -public sealed class RefreshTokenGenerator(ITokenSigningClient tokenSigningClient) +public sealed class RefreshTokenGenerator(ITokenSigningClient tokenSigningClient, TimeProvider timeProvider) { // Refresh tokens are stored as a persistent cookie in the user's browser. // Similar to Facebook and GitHub, when a user logs in, the session will be valid for a very long time. @@ -13,7 +13,7 @@ public sealed class RefreshTokenGenerator(ITokenSigningClient tokenSigningClient public string Generate(UserInfo userInfo) { - return GenerateRefreshToken(userInfo, RefreshTokenId.NewId(), 1, TimeProvider.System.GetUtcNow().AddHours(ValidForHours)); + return GenerateRefreshToken(userInfo, RefreshTokenId.NewId(), 1, timeProvider.GetUtcNow().AddHours(ValidForHours)); } public string Update(UserInfo userInfo, RefreshTokenId refreshTokenId, int currentRefreshTokenVersion, DateTimeOffset expires) @@ -38,6 +38,7 @@ private string GenerateRefreshToken(UserInfo userInfo, RefreshTokenId refreshTok }; return tokenDescriptor.GenerateToken( + timeProvider.GetUtcNow(), expires, tokenSigningClient.Issuer, tokenSigningClient.Audience, diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs index 4e0543f2f..a49092136 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/SecurityTokenDescriptorExtensions.cs @@ -7,8 +7,10 @@ internal static class SecurityTokenDescriptorExtensions { extension(SecurityTokenDescriptor tokenDescriptor) { - internal string GenerateToken(DateTimeOffset expires, string issuer, string audience, SigningCredentials signingCredentials) + internal string GenerateToken(DateTimeOffset now, DateTimeOffset expires, string issuer, string audience, SigningCredentials signingCredentials) { + tokenDescriptor.NotBefore = now.UtcDateTime; + tokenDescriptor.IssuedAt = now.UtcDateTime; tokenDescriptor.Expires = expires.UtcDateTime; tokenDescriptor.Issuer = issuer; tokenDescriptor.Audience = audience; diff --git a/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs index f41782b3e..828495dd5 100644 --- a/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.HealthChecks; using PlatformPlatform.SharedKernel.Authentication; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; @@ -66,6 +67,7 @@ public IServiceCollection AddSharedServices(Assembly[] assemblies) .AddSingleton(GetTokenSigningService()) .AddCrossServiceDataProtection() .AddSingleton(Settings.Current) + .AddTimeProvider() .AddAuthentication() .AddDefaultJsonSerializerOptions() .AddPersistenceHelpers() @@ -90,6 +92,12 @@ private IServiceCollection AddCrossServiceDataProtection() return services; } + private IServiceCollection AddTimeProvider() + { + services.TryAddSingleton(TimeProvider.System); // Use Try to allow tests to override with a fake TimeProvider + return services; + } + private IServiceCollection AddAuthentication() { return services diff --git a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs index 0957da30e..94a302364 100644 --- a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs @@ -79,12 +79,16 @@ private IHostApplicationBuilder AddDefaultBlobStorage() if (IsRunningInAzure) { var defaultBlobStorageUri = new Uri(Environment.GetEnvironmentVariable("BLOB_STORAGE_URL")!); - builder.Services.AddSingleton(_ => new BlobStorageClient(new BlobServiceClient(defaultBlobStorageUri, DefaultAzureCredential))); + builder.Services.AddSingleton(sp => + new BlobStorageClient(new BlobServiceClient(defaultBlobStorageUri, DefaultAzureCredential), sp.GetRequiredService()) + ); } else { var connectionString = builder.Configuration.GetConnectionString("blob-storage"); - builder.Services.AddSingleton(_ => new BlobStorageClient(new BlobServiceClient(connectionString))); + builder.Services.AddSingleton(sp => + new BlobStorageClient(new BlobServiceClient(connectionString), sp.GetRequiredService()) + ); } return builder; @@ -92,7 +96,7 @@ private IHostApplicationBuilder AddDefaultBlobStorage() /// /// Register different storage accounts for BlobStorage using .NET Keyed services, when a service needs to access - /// multiple storage accounts + /// multiple storage accounts. /// public IHostApplicationBuilder AddNamedBlobStorages((string ConnectionName, string EnvironmentVariable)?[] connections) { @@ -102,7 +106,7 @@ public IHostApplicationBuilder AddNamedBlobStorages((string ConnectionName, stri { var storageEndpointUri = new Uri(Environment.GetEnvironmentVariable(connection!.Value.EnvironmentVariable)!); builder.Services.AddKeyedSingleton(connection.Value.ConnectionName, - (_, _) => new BlobStorageClient(new BlobServiceClient(storageEndpointUri, DefaultAzureCredential)) + (sp, _) => new BlobStorageClient(new BlobServiceClient(storageEndpointUri, DefaultAzureCredential), sp.GetRequiredService()) ); } } @@ -112,7 +116,7 @@ public IHostApplicationBuilder AddNamedBlobStorages((string ConnectionName, stri foreach (var connection in connections) { builder.Services.AddKeyedSingleton(connection!.Value.ConnectionName, - (_, _) => new BlobStorageClient(new BlobServiceClient(connectionString)) + (sp, _) => new BlobStorageClient(new BlobServiceClient(connectionString), sp.GetRequiredService()) ); } } diff --git a/application/shared-kernel/SharedKernel/EntityFramework/ITimeProviderSource.cs b/application/shared-kernel/SharedKernel/EntityFramework/ITimeProviderSource.cs new file mode 100644 index 000000000..df2cb2cac --- /dev/null +++ b/application/shared-kernel/SharedKernel/EntityFramework/ITimeProviderSource.cs @@ -0,0 +1,9 @@ +namespace PlatformPlatform.SharedKernel.EntityFramework; + +/// +/// Exposes TimeProvider for use in Entity Framework Core interceptors without knowing the concrete DbContext type. +/// +internal interface ITimeProviderSource +{ + TimeProvider TimeProvider { get; } +} diff --git a/application/shared-kernel/SharedKernel/EntityFramework/SharedKernelDbContext.cs b/application/shared-kernel/SharedKernel/EntityFramework/SharedKernelDbContext.cs index a3ed37ff4..9081bd448 100644 --- a/application/shared-kernel/SharedKernel/EntityFramework/SharedKernelDbContext.cs +++ b/application/shared-kernel/SharedKernel/EntityFramework/SharedKernelDbContext.cs @@ -11,11 +11,13 @@ namespace PlatformPlatform.SharedKernel.EntityFramework; /// The SharedKernelDbContext class represents the Entity Framework Core DbContext for managing data access to the /// database, like creation, querying, and updating of entities. /// -public abstract class SharedKernelDbContext(DbContextOptions options, IExecutionContext executionContext) - : DbContext(options) where TContext : DbContext +public abstract class SharedKernelDbContext(DbContextOptions options, IExecutionContext executionContext, TimeProvider timeProvider) + : DbContext(options), ITimeProviderSource where TContext : DbContext { protected TenantId? TenantId => executionContext.TenantId; + public TimeProvider TimeProvider => timeProvider; + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); diff --git a/application/shared-kernel/SharedKernel/EntityFramework/UpdateAuditableEntitiesInterceptor.cs b/application/shared-kernel/SharedKernel/EntityFramework/UpdateAuditableEntitiesInterceptor.cs index 7adf39a2f..089c35dd3 100644 --- a/application/shared-kernel/SharedKernel/EntityFramework/UpdateAuditableEntitiesInterceptor.cs +++ b/application/shared-kernel/SharedKernel/EntityFramework/UpdateAuditableEntitiesInterceptor.cs @@ -32,6 +32,10 @@ private static void UpdateEntities(DbContextEventData eventData) { var dbContext = eventData.Context ?? throw new UnreachableException("The 'eventData.Context' property is unexpectedly null."); + var timeProvider = dbContext is ITimeProviderSource timeProviderSource + ? timeProviderSource.TimeProvider + : TimeProvider.System; + var audibleEntities = dbContext.ChangeTracker.Entries(); foreach (var entityEntry in audibleEntities) @@ -42,7 +46,7 @@ private static void UpdateEntities(DbContextEventData eventData) case EntityState.Added when entityEntry.Entity.CreatedAt == default: throw new UnreachableException("CreatedAt must be set before saving."); case EntityState.Modified: - entityEntry.Entity.UpdateModifiedAt(DateTime.UtcNow); + entityEntry.Entity.UpdateModifiedAt(timeProvider.GetUtcNow()); break; } } diff --git a/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs b/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs index 79d0aaedf..91fa16bd3 100644 --- a/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs +++ b/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs @@ -4,7 +4,7 @@ namespace PlatformPlatform.SharedKernel.Integrations.BlobStorage; -public class BlobStorageClient(BlobServiceClient blobServiceClient) : IBlobStorageClient +public class BlobStorageClient(BlobServiceClient blobServiceClient, TimeProvider timeProvider) : IBlobStorageClient { public async Task UploadAsync(string containerName, string blobName, string contentType, Stream stream, CancellationToken cancellationToken) { @@ -44,28 +44,29 @@ public async Task DeleteIfExistsAsync(string containerName, string blobNam public string GetSharedAccessSignature(string container, TimeSpan expiresIn) { var blobContainerClient = blobServiceClient.GetBlobContainerClient(container); - var dateTimeOffset = DateTimeOffset.UtcNow.Add(expiresIn); - return blobContainerClient.GenerateSasUri(BlobContainerSasPermissions.Read, dateTimeOffset).Query; + var expiresOn = timeProvider.GetUtcNow().Add(expiresIn); + return blobContainerClient.GenerateSasUri(BlobContainerSasPermissions.Read, expiresOn).Query; } public Uri GetBlobUriWithSharedAccessSignature(string container, string blobName, TimeSpan expiresIn) { var blobContainerClient = blobServiceClient.GetBlobContainerClient(container); var blobClient = blobContainerClient.GetBlobClient(blobName); - var dateTimeOffset = DateTimeOffset.UtcNow.Add(expiresIn); + var utcNow = timeProvider.GetUtcNow(); + var expiresOn = utcNow.Add(expiresIn); if (blobClient.CanGenerateSasUri) { - return blobClient.GenerateSasUri(BlobSasPermissions.Read, dateTimeOffset); + return blobClient.GenerateSasUri(BlobSasPermissions.Read, expiresOn); } - var userDelegationKey = blobServiceClient.GetUserDelegationKey(DateTimeOffset.UtcNow, dateTimeOffset); + var userDelegationKey = blobServiceClient.GetUserDelegationKey(utcNow, expiresOn); var builder = new BlobSasBuilder { BlobContainerName = container, BlobName = blobName, Resource = "b", - ExpiresOn = dateTimeOffset + ExpiresOn = expiresOn }; builder.SetPermissions(BlobSasPermissions.Read); diff --git a/application/shared-kernel/Tests/EntityFramework/MapStronglyTypedStringTests.cs b/application/shared-kernel/Tests/EntityFramework/MapStronglyTypedStringTests.cs index fac470e8b..5e1364ee8 100644 --- a/application/shared-kernel/Tests/EntityFramework/MapStronglyTypedStringTests.cs +++ b/application/shared-kernel/Tests/EntityFramework/MapStronglyTypedStringTests.cs @@ -17,7 +17,7 @@ public sealed class MapStronglyTypedStringTests : IDisposable public MapStronglyTypedStringTests() { var executionContext = new BackgroundWorkerExecutionContext(); - _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext); + _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext, TimeProvider.System); _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); _connection = (SqliteConnection)_testDbContext.Database.GetDbConnection(); } diff --git a/application/shared-kernel/Tests/EntityFramework/SaveChangesInterceptorTests.cs b/application/shared-kernel/Tests/EntityFramework/SaveChangesInterceptorTests.cs index 873d1e635..7a887c199 100644 --- a/application/shared-kernel/Tests/EntityFramework/SaveChangesInterceptorTests.cs +++ b/application/shared-kernel/Tests/EntityFramework/SaveChangesInterceptorTests.cs @@ -13,7 +13,7 @@ public sealed class SaveChangesInterceptorTests : IDisposable public SaveChangesInterceptorTests() { var executionContext = new BackgroundWorkerExecutionContext(); - _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext); + _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext, TimeProvider.System); _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); } diff --git a/application/shared-kernel/Tests/EntityFramework/UseStringForEnumsTests.cs b/application/shared-kernel/Tests/EntityFramework/UseStringForEnumsTests.cs index 9fc57430e..537155f2b 100644 --- a/application/shared-kernel/Tests/EntityFramework/UseStringForEnumsTests.cs +++ b/application/shared-kernel/Tests/EntityFramework/UseStringForEnumsTests.cs @@ -17,7 +17,7 @@ public sealed class UseStringForEnumsTests : IDisposable public UseStringForEnumsTests() { var executionContext = new BackgroundWorkerExecutionContext(); - _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext); + _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext, TimeProvider.System); _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); _connection = (SqliteConnection)_testDbContext.Database.GetDbConnection(); } diff --git a/application/shared-kernel/Tests/Persistence/RepositoryTests.cs b/application/shared-kernel/Tests/Persistence/RepositoryTests.cs index 1f11a9c02..161a2bdfc 100644 --- a/application/shared-kernel/Tests/Persistence/RepositoryTests.cs +++ b/application/shared-kernel/Tests/Persistence/RepositoryTests.cs @@ -16,7 +16,7 @@ public sealed class RepositoryTests : IDisposable public RepositoryTests() { var executionContext = new BackgroundWorkerExecutionContext(); - _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext); + _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext, TimeProvider.System); _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); _testAggregateRepository = new TestAggregateRepository(_testDbContext); } diff --git a/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs b/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs index d33e16820..3ffd4a428 100644 --- a/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs +++ b/application/shared-kernel/Tests/Persistence/SqliteConnectionExtensions.cs @@ -61,10 +61,12 @@ public void Insert(string tableName, (string, object?)[] columns) not null when valueType == typeof(byte[]) => SqliteType.Blob, not null when valueType == typeof(string) => SqliteType.Text, not null when valueType == typeof(DateTime) => SqliteType.Text, // SQLite stores dates as text + not null when valueType == typeof(DateTimeOffset) => SqliteType.Text, // SQLite stores dates as text not null when valueType == typeof(Guid) => SqliteType.Text, // Store GUIDs as text null => SqliteType.Text, // Handle null values by setting SqliteType to Text _ => SqliteType.Text // Default to Text if the type is unknown }; + var parameter = new SqliteParameter($"@{column.Item1}", sqliteType) { Value = column.Item2 ?? DBNull.Value }; command.Parameters.Add(parameter); } @@ -106,10 +108,12 @@ public void Update(string tableName, string idColumnName, object idValue, (strin not null when valueType == typeof(byte[]) => SqliteType.Blob, not null when valueType == typeof(string) => SqliteType.Text, not null when valueType == typeof(DateTime) => SqliteType.Text, // SQLite stores dates as text + not null when valueType == typeof(DateTimeOffset) => SqliteType.Text, // SQLite stores dates as text not null when valueType == typeof(Guid) => SqliteType.Text, // Store GUIDs as text null => SqliteType.Text, // Handle null values by setting SqliteType to Text _ => SqliteType.Text // Default to Text if the type is unknown }; + var parameter = new SqliteParameter($"@{column.Item1}", sqliteType) { Value = column.Item2 ?? DBNull.Value }; command.Parameters.Add(parameter); } diff --git a/application/shared-kernel/Tests/Persistence/UnitOfWorkTests.cs b/application/shared-kernel/Tests/Persistence/UnitOfWorkTests.cs index c482d6ff2..2c066b924 100644 --- a/application/shared-kernel/Tests/Persistence/UnitOfWorkTests.cs +++ b/application/shared-kernel/Tests/Persistence/UnitOfWorkTests.cs @@ -16,7 +16,7 @@ public sealed class UnitOfWorkTests : IDisposable public UnitOfWorkTests() { var executionContext = new BackgroundWorkerExecutionContext(); - _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext); + _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext, TimeProvider.System); _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); _unitOfWork = new UnitOfWork(_testDbContext); } diff --git a/application/shared-kernel/Tests/TestEntities/SqliteInMemoryDbContextFactory.cs b/application/shared-kernel/Tests/TestEntities/SqliteInMemoryDbContextFactory.cs index 4f533e0ca..78889ad69 100644 --- a/application/shared-kernel/Tests/TestEntities/SqliteInMemoryDbContextFactory.cs +++ b/application/shared-kernel/Tests/TestEntities/SqliteInMemoryDbContextFactory.cs @@ -8,10 +8,12 @@ public sealed class SqliteInMemoryDbContextFactory : IDisposable where T : Db { private readonly IExecutionContext _executionContext; private readonly SqliteConnection _sqliteConnection; + private readonly TimeProvider _timeProvider; - public SqliteInMemoryDbContextFactory(IExecutionContext executionContext) + public SqliteInMemoryDbContextFactory(IExecutionContext executionContext, TimeProvider timeProvider) { _executionContext = executionContext; + _timeProvider = timeProvider; _sqliteConnection = new SqliteConnection("DataSource=:memory:"); _sqliteConnection.Open(); } @@ -25,7 +27,7 @@ public T CreateContext() { var options = CreateOptions(); - var context = (T)Activator.CreateInstance(typeof(T), options, _executionContext)!; + var context = (T)Activator.CreateInstance(typeof(T), options, _executionContext, _timeProvider)!; context.Database.EnsureCreated(); return context; diff --git a/application/shared-kernel/Tests/TestEntities/TestDbContext.cs b/application/shared-kernel/Tests/TestEntities/TestDbContext.cs index a634c36d0..41b069432 100644 --- a/application/shared-kernel/Tests/TestEntities/TestDbContext.cs +++ b/application/shared-kernel/Tests/TestEntities/TestDbContext.cs @@ -4,8 +4,8 @@ namespace PlatformPlatform.SharedKernel.Tests.TestEntities; -public sealed class TestDbContext(DbContextOptions options, IExecutionContext executionContext) - : SharedKernelDbContext(options, executionContext) +public sealed class TestDbContext(DbContextOptions options, IExecutionContext executionContext, TimeProvider timeProvider) + : SharedKernelDbContext(options, executionContext, timeProvider) { public DbSet TestAggregates => Set(); From 041d17a8f0b4c9cac08ba01030f4bb9353f43d7f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 19 Dec 2025 01:47:11 +0100 Subject: [PATCH 2/2] Enable testable time in domain entities via TimeProviderAccessor --- .../SharedKernel/Domain/AudibleEntity.cs | 7 ++----- .../SharedKernel/TimeProviderAccessor.cs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 application/shared-kernel/SharedKernel/TimeProviderAccessor.cs diff --git a/application/shared-kernel/SharedKernel/Domain/AudibleEntity.cs b/application/shared-kernel/SharedKernel/Domain/AudibleEntity.cs index ea35b274a..79f35c2ee 100644 --- a/application/shared-kernel/SharedKernel/Domain/AudibleEntity.cs +++ b/application/shared-kernel/SharedKernel/Domain/AudibleEntity.cs @@ -8,15 +8,12 @@ namespace PlatformPlatform.SharedKernel.Domain; /// public abstract class AudibleEntity(T id) : Entity(id), IAuditableEntity where T : IComparable { - public DateTimeOffset CreatedAt { get; init; } = TimeProvider.System.GetUtcNow(); + public DateTimeOffset CreatedAt { get; init; } = TimeProviderAccessor.Current.GetUtcNow(); [ConcurrencyCheck] public DateTimeOffset? ModifiedAt { get; private set; } - /// - /// This method is used by the UpdateAuditableEntitiesInterceptor in the Infrastructure layer. - /// It's not intended to be used by the application, which is why it is implemented using an explicit interface. - /// + // Used by UpdateAuditableEntitiesInterceptor via explicit interface implementation void IAuditableEntity.UpdateModifiedAt(DateTimeOffset? modifiedAt) { ModifiedAt = modifiedAt; diff --git a/application/shared-kernel/SharedKernel/TimeProviderAccessor.cs b/application/shared-kernel/SharedKernel/TimeProviderAccessor.cs new file mode 100644 index 000000000..8553597b6 --- /dev/null +++ b/application/shared-kernel/SharedKernel/TimeProviderAccessor.cs @@ -0,0 +1,17 @@ +namespace PlatformPlatform.SharedKernel; + +/// +/// Provides access to the current TimeProvider using AsyncLocal for thread-safe, async-context-isolated access. +/// This allows domain entities to get the current time during construction while remaining testable. +/// Each async context (including parallel tests) has its own isolated TimeProvider instance. +/// +public static class TimeProviderAccessor +{ + private static readonly AsyncLocal CurrentTimeProvider = new(); + + public static TimeProvider Current + { + get => CurrentTimeProvider.Value ?? TimeProvider.System; + set => CurrentTimeProvider.Value = value; + } +}