From 4021affd8dce94cd31815a01794a64b330ea642d Mon Sep 17 00:00:00 2001 From: Ted Wollman <25165500+TheTedder@users.noreply.github.com> Date: Sun, 22 Sep 2024 00:36:11 -0400 Subject: [PATCH] Automatically Set CreatedAt and UpdatedAt (#244) * Create IHasCreationTimestamp interface. * Automatically set creation timestamps. * Implement IHasCreationTimestamp. * Don't manually set CreatedAt. * Map all Category properties on creation. * Fix up tests. * Don't override Equals or GetHashCode. * Create IHasUpdateTimestamp. * Implement IHasUpdateTimestamp. * Automatically set update timestamp. * Simplify clock initialization. * Don't manually set LB CreatedAt in test. * Remove erroneous readonly modifiers. --- LeaderboardBackend.Test/Categories.cs | 47 +++++++++++----- .../Features/Users/ConfirmAccountTests.cs | 49 ++++++++++------- .../Features/Users/RegistrationTests.cs | 16 ++++-- .../Features/Users/ResetPasswordTests.cs | 47 +++++++++------- .../Features/Users/TestRecoveryTests.cs | 37 +++++++------ LeaderboardBackend.Test/Leaderboards.cs | 55 ++++++++++++------- .../Controllers/CategoriesController.cs | 2 + .../Models/Entities/AccountConfirmation.cs | 2 +- .../Models/Entities/AccountRecovery.cs | 2 +- .../Models/Entities/ApplicationContext.cs | 29 +++++++++- .../Models/Entities/Category.cs | 19 +------ .../Models/Entities/IHasCreationTimestamp.cs | 8 +++ .../Models/Entities/IHasUpdateTimestamp.cs | 8 +++ .../Models/Entities/Leaderboard.cs | 16 +----- LeaderboardBackend/Models/Entities/Run.cs | 2 +- LeaderboardBackend/Models/Entities/User.cs | 12 +--- .../Impl/AccountConfirmationService.cs | 1 - .../Services/Impl/AccountRecoveryService.cs | 1 - .../Services/Impl/UserService.cs | 3 +- 19 files changed, 207 insertions(+), 149 deletions(-) create mode 100644 LeaderboardBackend/Models/Entities/IHasCreationTimestamp.cs create mode 100644 LeaderboardBackend/Models/Entities/IHasUpdateTimestamp.cs diff --git a/LeaderboardBackend.Test/Categories.cs b/LeaderboardBackend.Test/Categories.cs index 9ed071d2..704c01be 100644 --- a/LeaderboardBackend.Test/Categories.cs +++ b/LeaderboardBackend.Test/Categories.cs @@ -6,6 +6,11 @@ using LeaderboardBackend.Models.ViewModels; using LeaderboardBackend.Test.TestApi; using LeaderboardBackend.Test.TestApi.Extensions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using NodaTime; +using NodaTime.Testing; using NUnit.Framework; namespace LeaderboardBackend.Test; @@ -14,16 +19,23 @@ namespace LeaderboardBackend.Test; internal class Categories { private static TestApiClient _apiClient = null!; - private static TestApiFactory _factory = null!; + private static WebApplicationFactory _factory = null!; + private static readonly FakeClock _clock = new(new Instant()); private static string? _jwt; [OneTimeSetUp] public async Task OneTimeSetUp() { - _factory = new TestApiFactory(); - _apiClient = _factory.CreateTestApiClient(); + _factory = new TestApiFactory().WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_ => _clock); + }); + }); + _apiClient = new TestApiClient(_factory.CreateClient()); - _factory.ResetDatabase(); + PostgresDatabaseFixture.ResetDatabaseToTemplate(); _jwt = (await _apiClient.LoginAdminUser()).Token; } @@ -44,6 +56,9 @@ await _apiClient.Awaiting( [Test] public static async Task CreateCategory_GetCategory_OK() { + Instant now = Instant.FromUnixTimeSeconds(1); + _clock.Reset(now); + LeaderboardViewModel createdLeaderboard = await _apiClient.Post( "/leaderboards/create", new() @@ -58,27 +73,31 @@ public static async Task CreateCategory_GetCategory_OK() } ); + CreateCategoryRequest request = new() + { + Name = "1 Player", + Slug = "1_player", + LeaderboardId = createdLeaderboard.Id, + Info = null, + SortDirection = SortDirection.Ascending, + Type = RunType.Time + }; + CategoryViewModel createdCategory = await _apiClient.Post( "/categories/create", new() { - Body = new CreateCategoryRequest() - { - Name = "1 Player", - Slug = "1_player", - LeaderboardId = createdLeaderboard.Id, - Info = null, - SortDirection = SortDirection.Ascending, - Type = RunType.Time - }, + Body = request, Jwt = _jwt } ); + createdCategory.CreatedAt.Should().Be(now); + CategoryViewModel retrievedCategory = await _apiClient.Get( $"/api/category/{createdCategory?.Id}", new() { } ); - Assert.AreEqual(createdCategory, retrievedCategory); + retrievedCategory.Should().BeEquivalentTo(request, opts => opts.Excluding(c => c.LeaderboardId)); } } diff --git a/LeaderboardBackend.Test/Features/Users/ConfirmAccountTests.cs b/LeaderboardBackend.Test/Features/Users/ConfirmAccountTests.cs index 7a5d3b22..4fa0ff29 100644 --- a/LeaderboardBackend.Test/Features/Users/ConfirmAccountTests.cs +++ b/LeaderboardBackend.Test/Features/Users/ConfirmAccountTests.cs @@ -23,7 +23,13 @@ public class ConfirmAccountTests : IntegrationTestsBase [SetUp] public void Init() { - _scope = _factory.Services.CreateScope(); + _scope = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_ => _clock); + }); + }).Services.CreateScope(); _client = _factory.WithWebHostBuilder(builder => { @@ -44,12 +50,12 @@ public void TearDown() [Test] public async Task ConfirmAccount_BadConfirmationId() { - _clock.Reset(Instant.FromUnixTimeSeconds(1)); + Instant now = Instant.FromUnixTimeSeconds(1); + _clock.Reset(now); ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); AccountConfirmation confirmation = new() { - CreatedAt = Instant.FromUnixTimeSeconds(0), - ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)), + ExpiresAt = now.Plus(Duration.FromHours(1)), User = new() { Email = "test@email.com", @@ -60,6 +66,7 @@ public async Task ConfirmAccount_BadConfirmationId() context.AccountConfirmations.Add(confirmation); await context.SaveChangesAsync(); + confirmation.CreatedAt.Should().Be(now); HttpResponseMessage res = await _client.PutAsync(Routes.ConfirmAccount(Guid.NewGuid()), null); res.StatusCode.Should().Be(HttpStatusCode.NotFound); context.ChangeTracker.Clear(); @@ -78,13 +85,12 @@ public async Task ConfirmAccount_MalformedConfirmationId() [Test] public async Task ConfirmAccount_BadRole() { - _clock.Reset(Instant.FromUnixTimeSeconds(1)); + Instant now = Instant.FromUnixTimeSeconds(1); + _clock.Reset(now); ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); - AccountConfirmation confirmation = new() { - CreatedAt = Instant.FromUnixTimeSeconds(0), - ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)), + ExpiresAt = now.Plus(Duration.FromHours(1)), User = new() { Email = "test@email.com", @@ -96,7 +102,7 @@ public async Task ConfirmAccount_BadRole() context.AccountConfirmations.Add(confirmation); await context.SaveChangesAsync(); - + confirmation.CreatedAt.Should().Be(now); HttpResponseMessage res = await _client.PutAsync(Routes.ConfirmAccount(confirmation.Id), null); res.StatusCode.Should().Be(HttpStatusCode.Conflict); context.ChangeTracker.Clear(); @@ -107,13 +113,13 @@ public async Task ConfirmAccount_BadRole() [Test] public async Task ConfirmAccount_Expired() { - _clock.Reset(Instant.FromUnixTimeSeconds(1) + Duration.FromHours(1)); + Instant now = Instant.FromUnixTimeSeconds(1); + _clock.Reset(now); ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); AccountConfirmation confirmation = new() { - CreatedAt = Instant.FromUnixTimeSeconds(0), - ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)), + ExpiresAt = now.Plus(Duration.FromHours(1)), User = new() { Email = "test@email.com", @@ -124,6 +130,7 @@ public async Task ConfirmAccount_Expired() context.AccountConfirmations.Add(confirmation); await context.SaveChangesAsync(); + _clock.Reset(now + Duration.FromHours(2)); HttpResponseMessage res = await _client.PutAsync(Routes.ConfirmAccount(confirmation.Id), null); res.StatusCode.Should().Be(HttpStatusCode.NotFound); context.ChangeTracker.Clear(); @@ -135,14 +142,14 @@ public async Task ConfirmAccount_Expired() [Test] public async Task ConfirmAccount_AlreadyUsed() { - _clock.Reset(Instant.FromUnixTimeSeconds(1)); + Instant now = Instant.FromUnixTimeSeconds(1); + _clock.Reset(now); ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); AccountConfirmation confirmation = new() { - CreatedAt = Instant.FromUnixTimeSeconds(0), - ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)), - UsedAt = Instant.FromUnixTimeSeconds(5), + ExpiresAt = now.Plus(Duration.FromHours(1)), + UsedAt = now.Plus(Duration.FromSeconds(5)), User = new() { Email = "test@email.com", @@ -153,6 +160,7 @@ public async Task ConfirmAccount_AlreadyUsed() context.AccountConfirmations.Add(confirmation); await context.SaveChangesAsync(); + _clock.AdvanceMinutes(1); HttpResponseMessage res = await _client.PutAsync(Routes.ConfirmAccount(confirmation.Id), null); res.StatusCode.Should().Be(HttpStatusCode.NotFound); context.ChangeTracker.Clear(); @@ -163,12 +171,12 @@ public async Task ConfirmAccount_AlreadyUsed() [Test] public async Task ConfirmAccount_Success() { - _clock.Reset(Instant.FromUnixTimeSeconds(1)); + Instant now = Instant.FromUnixTimeSeconds(1); + _clock.Reset(now); AccountConfirmation confirmation = new() { - CreatedAt = Instant.FromUnixTimeSeconds(0), - ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)), + ExpiresAt = now.Plus(Duration.FromHours(1)), User = new() { Email = "test@email.com", @@ -180,11 +188,12 @@ public async Task ConfirmAccount_Success() ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); context.AccountConfirmations.Add(confirmation); await context.SaveChangesAsync(); + _clock.AdvanceMinutes(5); HttpResponseMessage res = await _client.PutAsync(Routes.ConfirmAccount(confirmation.Id), null); res.Should().HaveStatusCode(HttpStatusCode.OK); context.ChangeTracker.Clear(); AccountConfirmation? conf = await context.AccountConfirmations.Include(c => c.User).SingleOrDefaultAsync(c => c.Id == confirmation.Id); - conf!.UsedAt.Should().Be(Instant.FromUnixTimeSeconds(1)); + conf!.UsedAt.Should().Be(now.Plus(Duration.FromMinutes(5))); conf!.User.Role.Should().Be(UserRole.Confirmed); } } diff --git a/LeaderboardBackend.Test/Features/Users/RegistrationTests.cs b/LeaderboardBackend.Test/Features/Users/RegistrationTests.cs index 291db890..c094c794 100644 --- a/LeaderboardBackend.Test/Features/Users/RegistrationTests.cs +++ b/LeaderboardBackend.Test/Features/Users/RegistrationTests.cs @@ -32,28 +32,33 @@ public class RegistrationTests : IntegrationTestsBase public async Task Register_ValidRequest_CreatesAndReturnsUser() { Mock emailSenderMock = new(); + Instant now = Instant.FromUnixTimeSeconds(1); + using HttpClient client = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.AddScoped(_ => emailSenderMock.Object); - services.AddSingleton(_ => new(Instant.FromUnixTimeSeconds(1))); + services.AddSingleton(_ => new(now)); }); }) .CreateClient(); + RegisterRequest request = _registerReqFaker.Generate(); HttpResponseMessage res = await client.PostAsJsonAsync(Routes.REGISTER, request); res.Should().HaveStatusCode(HttpStatusCode.Created); UserViewModel? content = await res.Content.ReadFromJsonAsync(TestInitCommonFields.JsonSerializerOptions); - content.Should().NotBeNull().And.BeEquivalentTo(new UserViewModel + + content.Should().NotBeNull().And.Be(new UserViewModel { Id = content!.Id, Username = request.Username, Role = UserRole.Registered, - CreatedAt = Instant.FromUnixTimeSeconds(1) + CreatedAt = now }); + emailSenderMock.Verify(x => x.EnqueueEmailAsync( It.IsAny(), @@ -66,14 +71,17 @@ public async Task Register_ValidRequest_CreatesAndReturnsUser() using IServiceScope scope = _factory.Services.CreateScope(); using ApplicationContext dbContext = scope.ServiceProvider.GetRequiredService(); User? createdUser = dbContext.Users.FirstOrDefault(u => u.Id == content.Id); + createdUser.Should().NotBeNull().And.BeEquivalentTo(new User { Id = content!.Id, Password = createdUser!.Password, Username = request.Username, Email = request.Email, - Role = UserRole.Registered + Role = UserRole.Registered, + CreatedAt = now }); + AccountConfirmation confirmation = dbContext.AccountConfirmations.First(c => c.UserId == createdUser.Id); confirmation.Should().NotBeNull(); confirmation.CreatedAt.ToUnixTimeSeconds().Should().Be(1); diff --git a/LeaderboardBackend.Test/Features/Users/ResetPasswordTests.cs b/LeaderboardBackend.Test/Features/Users/ResetPasswordTests.cs index 79646479..14f94b95 100644 --- a/LeaderboardBackend.Test/Features/Users/ResetPasswordTests.cs +++ b/LeaderboardBackend.Test/Features/Users/ResetPasswordTests.cs @@ -40,7 +40,11 @@ public void OneTimeSetUp() [SetUp] public void Init() { - _scope = _factory.Services.CreateScope(); + _scope = _factory.WithWebHostBuilder( + builder => builder.ConfigureTestServices( + services => services.AddSingleton(_ => _clock) + ) + ).Services.CreateScope(); } [TearDown] @@ -75,8 +79,7 @@ public async Task ResetPassword_Expired() AccountRecovery recovery = new() { - CreatedAt = Instant.FromUnixTimeSeconds(0), - ExpiresAt = Instant.FromUnixTimeSeconds(0) + Duration.FromHours(1), + ExpiresAt = _clock.GetCurrentInstant() + Duration.FromHours(1), User = new() { Email = $"pwdresettestuser{userNumber}@email.com", @@ -89,6 +92,8 @@ public async Task ResetPassword_Expired() context.AccountRecoveries.Add(recovery); await context.SaveChangesAsync(); + _clock.AdvanceHours(2); + HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(recovery.Id), new ChangePasswordRequest { Password = "AValidP4ssword" @@ -117,20 +122,23 @@ public async Task ResetPassword_NotMostRecent() AccountRecovery recovery1 = new() { - CreatedAt = Instant.FromUnixTimeSeconds(20), - ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), + ExpiresAt = _clock.GetCurrentInstant() + Duration.FromHours(1), User = user }; + context.AccountRecoveries.Add(recovery1); + await context.SaveChangesAsync(); + _clock.AdvanceMinutes(1); + AccountRecovery recovery2 = new() { - CreatedAt = Instant.FromUnixTimeSeconds(30), - ExpiresAt = Instant.FromUnixTimeSeconds(30) + Duration.FromHours(1), + ExpiresAt = _clock.GetCurrentInstant() + Duration.FromHours(1), User = user }; - context.AccountRecoveries.AddRange(recovery1, recovery2); + context.AccountRecoveries.Add(recovery2); await context.SaveChangesAsync(); + _clock.AdvanceMinutes(1); HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(recovery1.Id), new ChangePasswordRequest { @@ -152,9 +160,8 @@ public async Task ResetPassword_AlreadyUsed() AccountRecovery recovery = new() { - CreatedAt = Instant.FromUnixTimeSeconds(20), - ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), - UsedAt = Instant.FromUnixTimeSeconds(30), + ExpiresAt = _clock.GetCurrentInstant() + Duration.FromHours(1), + UsedAt = _clock.GetCurrentInstant() + Duration.FromMinutes(1), User = new() { Email = $"pwdresettestuser{userNumber}@email.com", @@ -166,6 +173,7 @@ public async Task ResetPassword_AlreadyUsed() context.AccountRecoveries.Add(recovery); await context.SaveChangesAsync(); + _clock.AdvanceMinutes(2); HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(recovery.Id), new ChangePasswordRequest { @@ -175,7 +183,7 @@ public async Task ResetPassword_AlreadyUsed() res.Should().HaveStatusCode(System.Net.HttpStatusCode.NotFound); context.ChangeTracker.Clear(); recovery = await context.AccountRecoveries.Include(ar => ar.User).SingleAsync(ar => ar.Id == recovery.Id); - recovery.UsedAt.Should().Be(Instant.FromUnixTimeSeconds(30)); + recovery.UsedAt.Should().Be(_clock.GetCurrentInstant() - Duration.FromMinutes(1)); BCryptNet.EnhancedVerify("P4ssword", recovery.User.Password).Should().BeTrue(); } @@ -187,8 +195,7 @@ public async Task ResetPassword_Banned() AccountRecovery recovery = new() { - CreatedAt = Instant.FromUnixTimeSeconds(20), - ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), + ExpiresAt = _clock.GetCurrentInstant() + Duration.FromHours(1), User = new() { Email = $"pwdresettestuser{userNumber}@email.com", @@ -226,8 +233,7 @@ public async Task ResetPassword_BadPassword(string pwd) AccountRecovery recovery = new() { - CreatedAt = Instant.FromUnixTimeSeconds(20), - ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), + ExpiresAt = _clock.GetCurrentInstant() + Duration.FromHours(1), User = new() { Email = $"pwdresettestuser{userNumber}@email.com", @@ -268,8 +274,7 @@ public async Task ResetPassword_SamePassword() AccountRecovery recovery = new() { - CreatedAt = Instant.FromUnixTimeSeconds(20), - ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), + ExpiresAt = _clock.GetCurrentInstant() + Duration.FromHours(1), User = new() { Email = $"pwdresettestuser{userNumber}@email.com", @@ -303,8 +308,7 @@ public async Task ResetPassword_Success(UserRole role) AccountRecovery recovery = new() { - CreatedAt = Instant.FromUnixTimeSeconds(20), - ExpiresAt = Instant.FromUnixTimeSeconds(20) + Duration.FromHours(1), + ExpiresAt = _clock.GetCurrentInstant() + Duration.FromHours(1), User = new() { Email = $"pwdresettestuser{userNumber}@email.com", @@ -316,6 +320,7 @@ public async Task ResetPassword_Success(UserRole role) context.AccountRecoveries.Add(recovery); await context.SaveChangesAsync(); + _clock.AdvanceMinutes(1); HttpResponseMessage res = await _client.PostAsJsonAsync(Routes.RecoverAccount(recovery.Id), new ChangePasswordRequest { @@ -325,7 +330,7 @@ public async Task ResetPassword_Success(UserRole role) res.Should().HaveStatusCode(System.Net.HttpStatusCode.OK); context.ChangeTracker.Clear(); recovery = await context.AccountRecoveries.Include(ar => ar.User).SingleAsync(ar => ar.Id == recovery.Id); - recovery.UsedAt.Should().Be(Instant.FromUnixTimeSeconds(10) + Duration.FromHours(1)); + recovery.UsedAt.Should().Be(_clock.GetCurrentInstant()); BCryptNet.EnhancedVerify("AValidP4ssword", recovery.User.Password).Should().BeTrue(); } } diff --git a/LeaderboardBackend.Test/Features/Users/TestRecoveryTests.cs b/LeaderboardBackend.Test/Features/Users/TestRecoveryTests.cs index 8902b98d..e2c174dc 100644 --- a/LeaderboardBackend.Test/Features/Users/TestRecoveryTests.cs +++ b/LeaderboardBackend.Test/Features/Users/TestRecoveryTests.cs @@ -31,7 +31,11 @@ public void OneTimeSetUp() public void Init() { _factory.ResetDatabase(); - _scope = _factory.Services.CreateScope(); + _scope = _factory.WithWebHostBuilder(builder => + builder.ConfigureTestServices(services => + services.AddSingleton(_ => _clock) + ) + ).Services.CreateScope(); } [TearDown] @@ -48,13 +52,11 @@ public async Task TestRecovery_BadRecoveryId(string id) [Test] public async Task TestRecovery_Expired() { - _clock.Reset(Instant.FromUnixTimeSeconds(1) + Duration.FromHours(2)); ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); AccountRecovery recovery = new() { - CreatedAt = Instant.FromUnixTimeSeconds(0), - ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)), + ExpiresAt = _clock.GetCurrentInstant().Plus(Duration.FromHours(1)), User = new() { Email = "test@email.com", @@ -66,6 +68,7 @@ public async Task TestRecovery_Expired() context.AccountRecoveries.Add(recovery); await context.SaveChangesAsync(); + _clock.AdvanceHours(2); HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery.Id)); res.StatusCode.Should().Be(HttpStatusCode.NotFound); } @@ -73,7 +76,6 @@ public async Task TestRecovery_Expired() [Test] public async Task TestRecovery_Old() { - _clock.Reset(Instant.FromUnixTimeSeconds(10)); ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); User user = new() @@ -87,20 +89,23 @@ public async Task TestRecovery_Old() AccountRecovery recovery1 = new() { - CreatedAt = Instant.FromUnixTimeSeconds(0), - ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)), + ExpiresAt = _clock.GetCurrentInstant().Plus(Duration.FromHours(1)), User = user }; + context.AccountRecoveries.Add(recovery1); + await context.SaveChangesAsync(); + _clock.AdvanceMinutes(1); + AccountRecovery recovery2 = new() { - CreatedAt = Instant.FromUnixTimeSeconds(5), - ExpiresAt = Instant.FromUnixTimeSeconds(5).Plus(Duration.FromHours(1)), + ExpiresAt = _clock.GetCurrentInstant().Plus(Duration.FromHours(1)), User = user }; - await context.AccountRecoveries.AddRangeAsync(recovery1, recovery2); + context.AccountRecoveries.Add(recovery2); await context.SaveChangesAsync(); + _clock.AdvanceMinutes(1); HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery1.Id)); res.Should().HaveStatusCode(HttpStatusCode.NotFound); } @@ -108,14 +113,12 @@ public async Task TestRecovery_Old() [Test] public async Task TestRecovery_Used() { - _clock.Reset(Instant.FromUnixTimeSeconds(10)); ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); AccountRecovery recovery = new() { - CreatedAt = Instant.FromUnixTimeSeconds(0), - ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)), - UsedAt = Instant.FromUnixTimeSeconds(5), + ExpiresAt = _clock.GetCurrentInstant().Plus(Duration.FromHours(1)), + UsedAt = _clock.GetCurrentInstant().Plus(Duration.FromMinutes(1)), User = new() { Email = "test@email.com", @@ -127,6 +130,7 @@ public async Task TestRecovery_Used() context.AccountRecoveries.Add(recovery); await context.SaveChangesAsync(); + _clock.AdvanceMinutes(2); HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery.Id)); res.Should().HaveStatusCode(HttpStatusCode.NotFound); } @@ -137,13 +141,11 @@ public async Task TestRecovery_Used() [TestCase(UserRole.Registered, HttpStatusCode.OK)] public async Task TestRecovery_Roles(UserRole role, HttpStatusCode expected) { - _clock.Reset(Instant.FromUnixTimeSeconds(1)); ApplicationContext context = _scope.ServiceProvider.GetRequiredService(); AccountRecovery recovery = new() { - CreatedAt = Instant.FromUnixTimeSeconds(0), - ExpiresAt = Instant.FromUnixTimeSeconds(0).Plus(Duration.FromHours(1)), + ExpiresAt = _clock.GetCurrentInstant().Plus(Duration.FromHours(1)), User = new() { Email = "test@email.com", @@ -155,6 +157,7 @@ public async Task TestRecovery_Roles(UserRole role, HttpStatusCode expected) context.AccountRecoveries.Add(recovery); await context.SaveChangesAsync(); + recovery.CreatedAt.Should().Be(_clock.GetCurrentInstant()); HttpResponseMessage res = await _client.GetAsync(Routes.RecoverAccount(recovery.Id)); res.Should().HaveStatusCode(expected); } diff --git a/LeaderboardBackend.Test/Leaderboards.cs b/LeaderboardBackend.Test/Leaderboards.cs index dad523eb..dbb288fd 100644 --- a/LeaderboardBackend.Test/Leaderboards.cs +++ b/LeaderboardBackend.Test/Leaderboards.cs @@ -2,14 +2,18 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Threading; using System.Threading.Tasks; using LeaderboardBackend.Models.Entities; using LeaderboardBackend.Models.Requests; using LeaderboardBackend.Models.ViewModels; using LeaderboardBackend.Test.TestApi; using LeaderboardBackend.Test.TestApi.Extensions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using NodaTime; +using NodaTime.Testing; using NUnit.Framework; namespace LeaderboardBackend.Test; @@ -18,7 +22,8 @@ namespace LeaderboardBackend.Test; internal class Leaderboards { private static TestApiClient _apiClient = null!; - private static TestApiFactory _factory = null!; + private static WebApplicationFactory _factory = null!; + private static readonly FakeClock _clock = new(new()); private static string? _jwt; private readonly Faker _createBoardReqFaker = @@ -30,10 +35,17 @@ internal class Leaderboards [OneTimeSetUp] public async Task OneTimeSetUp() { - _factory = new TestApiFactory(); - _apiClient = _factory.CreateTestApiClient(); + _factory = new TestApiFactory().WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddSingleton(_ => _clock); + }); + }); + + _apiClient = new TestApiClient(_factory.CreateClient()); - _factory.ResetDatabase(); + PostgresDatabaseFixture.ResetDatabaseToTemplate(); _jwt = (await _apiClient.LoginAdminUser()).Token; } @@ -52,26 +64,29 @@ public async Task GetLeaderboard_NotFound() => await FluentActions.Awaiting( [Test] public async Task CreateLeaderboard_GetLeaderboard_OK() { - CreateLeaderboardRequest req = _createBoardReqFaker.Generate(); + CreateLeaderboardRequest req = new() + { + Name = "Super Mario 64", + Slug = "super-mario-64", + Info = "The iQue is not allowed." + }; + + Instant now = Instant.FromUnixTimeSeconds(1); + _clock.Reset(now); + LeaderboardViewModel createdLeaderboard = await _apiClient.Post( "/leaderboards/create", new() { Body = req, Jwt = _jwt } ); + createdLeaderboard.CreatedAt.Should().Be(now); + LeaderboardViewModel retrievedLeaderboard = await _apiClient.Get( $"/api/leaderboard/{createdLeaderboard?.Id}", new() ); - createdLeaderboard.Should().NotBeNull(); - (string, string) expectedCreatedBoard = ValueTuple.Create(req.Name, req.Slug); - (string, string) actualCreatedBoard = ValueTuple.Create( - createdLeaderboard!.Name, - createdLeaderboard.Slug - ); - expectedCreatedBoard.Should().BeEquivalentTo(actualCreatedBoard); - - createdLeaderboard.Should().BeEquivalentTo(retrievedLeaderboard); + retrievedLeaderboard.Should().BeEquivalentTo(req); } [Test] @@ -80,8 +95,8 @@ public async Task CreateLeaderboards_GetLeaderboards() IEnumerable> boardCreationTasks = _createBoardReqFaker .GenerateBetween(3, 10) .Select( - req => - _apiClient.Post( + async req => + await _apiClient.Post( "/leaderboards/create", new() { Body = req, Jwt = _jwt } ) @@ -122,7 +137,7 @@ await _apiClient.Post( new() ); - leaderboard.Should().BeEquivalentTo(createdLeaderboard); + leaderboard.Should().BeEquivalentTo(createReqBody); } [Test] @@ -150,13 +165,13 @@ public async Task GetLeaderboards_Deleted_BySlug_NotFound() { Name = "Should 404", Slug = "should-404", - CreatedAt = Instant.FromUnixTimeSeconds(0), - UpdatedAt = Instant.FromUnixTimeSeconds(0), - DeletedAt = Instant.FromUnixTimeSeconds(0), + UpdatedAt = _clock.GetCurrentInstant() + Duration.FromMinutes(1), + DeletedAt = _clock.GetCurrentInstant() + Duration.FromMinutes(1), }; context.Leaderboards.Add(board); await context.SaveChangesAsync(); + _clock.AdvanceMinutes(2); Func> act = async () => await _apiClient.Get($"/api/leaderboard?slug={board.Slug}", new()); await act.Should().ThrowAsync().Where(e => e.Response.StatusCode == HttpStatusCode.NotFound); diff --git a/LeaderboardBackend/Controllers/CategoriesController.cs b/LeaderboardBackend/Controllers/CategoriesController.cs index 0b7be4c1..944484a2 100644 --- a/LeaderboardBackend/Controllers/CategoriesController.cs +++ b/LeaderboardBackend/Controllers/CategoriesController.cs @@ -45,6 +45,8 @@ [FromBody] CreateCategoryRequest request Slug = request.Slug, Info = request.Info, LeaderboardId = request.LeaderboardId, + SortDirection = request.SortDirection, + Type = request.Type }; await categoryService.CreateCategory(category); diff --git a/LeaderboardBackend/Models/Entities/AccountConfirmation.cs b/LeaderboardBackend/Models/Entities/AccountConfirmation.cs index 919f23c1..3b90dc85 100644 --- a/LeaderboardBackend/Models/Entities/AccountConfirmation.cs +++ b/LeaderboardBackend/Models/Entities/AccountConfirmation.cs @@ -6,7 +6,7 @@ namespace LeaderboardBackend.Models.Entities; /// /// Represents a user account confirmation. /// -public class AccountConfirmation +public class AccountConfirmation : IHasCreationTimestamp { /// /// The unique identifier of the `AccountConfirmation`.
diff --git a/LeaderboardBackend/Models/Entities/AccountRecovery.cs b/LeaderboardBackend/Models/Entities/AccountRecovery.cs index ebf38bf8..0e7c34a1 100644 --- a/LeaderboardBackend/Models/Entities/AccountRecovery.cs +++ b/LeaderboardBackend/Models/Entities/AccountRecovery.cs @@ -6,7 +6,7 @@ namespace LeaderboardBackend.Models.Entities; /// /// Represents an account recovery attempt for a `User`. /// -public class AccountRecovery +public class AccountRecovery : IHasCreationTimestamp { /// /// The unique identifier of the `AccountRecovery`.
diff --git a/LeaderboardBackend/Models/Entities/ApplicationContext.cs b/LeaderboardBackend/Models/Entities/ApplicationContext.cs index d4ad343e..bfccd3c1 100644 --- a/LeaderboardBackend/Models/Entities/ApplicationContext.cs +++ b/LeaderboardBackend/Models/Entities/ApplicationContext.cs @@ -1,12 +1,16 @@ using System.Data; using System.Reflection; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using NodaTime; using Npgsql; namespace LeaderboardBackend.Models.Entities; public class ApplicationContext : DbContext { + private readonly IClock _clock; + [Obsolete] static ApplicationContext() { @@ -16,8 +20,29 @@ static ApplicationContext() NpgsqlConnection.GlobalTypeMapper.MapEnum(); } - public ApplicationContext(DbContextOptions options) - : base(options) { } + private void AddCreationTimestamp(object? sender, EntityEntryEventArgs e) + { + if (e.Entry.State is EntityState.Added && e.Entry.Entity is IHasCreationTimestamp entity) + { + entity.CreatedAt = _clock.GetCurrentInstant(); + } + } + + private void SetUpdateTimestamp(object? sender, EntityEntryEventArgs e) + { + if (e.Entry.State is EntityState.Modified && e.Entry.Entity is IHasUpdateTimestamp entity) + { + entity.UpdatedAt = _clock.GetCurrentInstant(); + } + } + + public ApplicationContext(DbContextOptions options, IClock clock) + : base(options) + { + _clock = clock; + ChangeTracker.Tracked += AddCreationTimestamp; + ChangeTracker.Tracked += SetUpdateTimestamp; + } public DbSet AccountRecoveries { get; set; } = null!; public DbSet Categories { get; set; } = null!; diff --git a/LeaderboardBackend/Models/Entities/Category.cs b/LeaderboardBackend/Models/Entities/Category.cs index 053e2354..436259e6 100644 --- a/LeaderboardBackend/Models/Entities/Category.cs +++ b/LeaderboardBackend/Models/Entities/Category.cs @@ -15,7 +15,7 @@ public enum SortDirection /// Represents a `Category` tied to a `Leaderboard`. ///
[Index(nameof(Slug), IsUnique = true)] -public class Category +public class Category : IHasUpdateTimestamp { /// /// The unique identifier of the `Category`.
@@ -80,21 +80,4 @@ public class Category /// The time at which the Category was deleted, or if the Category has not been deleted. ///
public Instant? DeletedAt { get; set; } - - public override bool Equals(object? obj) - { - return obj is Category category - && Id == category.Id - && Name == category.Name - && Slug == category.Slug - && Info == category.Info - && SortDirection == category.SortDirection - && Type == category.Type - && LeaderboardId == category.LeaderboardId; - } - - public override int GetHashCode() - { - return HashCode.Combine(Id, Name, Slug, LeaderboardId, Info, SortDirection, Type); - } } diff --git a/LeaderboardBackend/Models/Entities/IHasCreationTimestamp.cs b/LeaderboardBackend/Models/Entities/IHasCreationTimestamp.cs new file mode 100644 index 00000000..22274966 --- /dev/null +++ b/LeaderboardBackend/Models/Entities/IHasCreationTimestamp.cs @@ -0,0 +1,8 @@ +using NodaTime; + +namespace LeaderboardBackend.Models.Entities; + +public interface IHasCreationTimestamp +{ + Instant CreatedAt { get; set; } +} diff --git a/LeaderboardBackend/Models/Entities/IHasUpdateTimestamp.cs b/LeaderboardBackend/Models/Entities/IHasUpdateTimestamp.cs new file mode 100644 index 00000000..6b432f32 --- /dev/null +++ b/LeaderboardBackend/Models/Entities/IHasUpdateTimestamp.cs @@ -0,0 +1,8 @@ +using NodaTime; + +namespace LeaderboardBackend.Models.Entities; + +public interface IHasUpdateTimestamp : IHasCreationTimestamp +{ + Instant? UpdatedAt { get; set; } +} diff --git a/LeaderboardBackend/Models/Entities/Leaderboard.cs b/LeaderboardBackend/Models/Entities/Leaderboard.cs index 0a982cde..10aab995 100644 --- a/LeaderboardBackend/Models/Entities/Leaderboard.cs +++ b/LeaderboardBackend/Models/Entities/Leaderboard.cs @@ -9,7 +9,7 @@ namespace LeaderboardBackend.Models.Entities; /// /// Represents a collection of `Category` entities. /// -public class Leaderboard +public class Leaderboard : IHasUpdateTimestamp { /// /// The unique identifier of the `Leaderboard`.
@@ -58,20 +58,6 @@ public class Leaderboard /// A collection of `Category` entities for the `Leaderboard`. ///
public List? Categories { get; set; } - - public override bool Equals(object? obj) - { - return obj is Leaderboard leaderboard - && Id == leaderboard.Id - && Name == leaderboard.Name - && Slug == leaderboard.Slug - && Info == leaderboard.Info; - } - - public override int GetHashCode() - { - return HashCode.Combine(Id, Name, Slug, Info); - } } public class LeaderboardEntityTypeConfig : IEntityTypeConfiguration diff --git a/LeaderboardBackend/Models/Entities/Run.cs b/LeaderboardBackend/Models/Entities/Run.cs index ae1c41c7..589d0aea 100644 --- a/LeaderboardBackend/Models/Entities/Run.cs +++ b/LeaderboardBackend/Models/Entities/Run.cs @@ -6,7 +6,7 @@ namespace LeaderboardBackend.Models.Entities; /// /// Represents an entry on a `Category`. /// -public class Run +public class Run : IHasUpdateTimestamp { /// /// The unique identifier of the `Run`.
diff --git a/LeaderboardBackend/Models/Entities/User.cs b/LeaderboardBackend/Models/Entities/User.cs index c995dea8..eb27e5d8 100644 --- a/LeaderboardBackend/Models/Entities/User.cs +++ b/LeaderboardBackend/Models/Entities/User.cs @@ -18,7 +18,7 @@ public enum UserRole /// /// Represents a user account registered on the website. /// -public class User +public class User : IHasCreationTimestamp { /// /// The unique identifier of the `User`.
@@ -80,16 +80,6 @@ public class User public Instant CreatedAt { get; set; } public bool IsAdmin => Role == UserRole.Administrator; - - public override bool Equals(object? obj) - { - return obj is User user && Id.Equals(user.Id); - } - - public override int GetHashCode() - { - return HashCode.Combine(Id, Username, Email); - } } public class UserEntityTypeConfig : IEntityTypeConfiguration diff --git a/LeaderboardBackend/Services/Impl/AccountConfirmationService.cs b/LeaderboardBackend/Services/Impl/AccountConfirmationService.cs index 0d48d333..a723534c 100644 --- a/LeaderboardBackend/Services/Impl/AccountConfirmationService.cs +++ b/LeaderboardBackend/Services/Impl/AccountConfirmationService.cs @@ -43,7 +43,6 @@ public async Task CreateConfirmationAndSendEmail(User AccountConfirmation newConfirmation = new() { - CreatedAt = now, ExpiresAt = now + Duration.FromHours(1), UserId = user.Id, }; diff --git a/LeaderboardBackend/Services/Impl/AccountRecoveryService.cs b/LeaderboardBackend/Services/Impl/AccountRecoveryService.cs index a1728653..5d252fe6 100644 --- a/LeaderboardBackend/Services/Impl/AccountRecoveryService.cs +++ b/LeaderboardBackend/Services/Impl/AccountRecoveryService.cs @@ -43,7 +43,6 @@ public async Task CreateRecoveryAndSendEmail(User user) AccountRecovery recovery = new() { - CreatedAt = now, ExpiresAt = now + Duration.FromHours(1), User = user }; diff --git a/LeaderboardBackend/Services/Impl/UserService.cs b/LeaderboardBackend/Services/Impl/UserService.cs index f6bf1812..081e856f 100644 --- a/LeaderboardBackend/Services/Impl/UserService.cs +++ b/LeaderboardBackend/Services/Impl/UserService.cs @@ -9,7 +9,7 @@ namespace LeaderboardBackend.Services; -public class UserService(ApplicationContext applicationContext, IAuthService authService, IClock clock) : IUserService +public class UserService(ApplicationContext applicationContext, IAuthService authService) : IUserService { // TODO: Convert return sig to Task public async Task GetUserById(Guid id) @@ -86,7 +86,6 @@ public async Task CreateUser(RegisterRequest request) Email = request.Email, Password = BCryptNet.EnhancedHashPassword(request.Password), Role = UserRole.Registered, - CreatedAt = clock.GetCurrentInstant() }; applicationContext.Users.Add(newUser);