From f6d38762799e15b439350599904240028f8ad3fc Mon Sep 17 00:00:00 2001 From: aligneddev Date: Wed, 8 Jul 2026 15:57:13 -0500 Subject: [PATCH] used the msec:farley-score skill --- docs/agent/testing-and-quality-gates.md | 10 + .../GetAdvancedDashboardServiceTests.cs | 43 +- .../Dashboard/GetDashboardServiceTests.cs | 14 +- .../GetYearStatsDashboardServiceTests.cs | 26 +- .../Imports/CsvRideImportServiceTests.cs | 12 +- .../Rides/WeatherLookupServiceTests.cs | 27 +- .../RidesApplicationServiceTests.Edit.cs | 332 +++++++ .../RidesApplicationServiceTests.History.cs | 222 +++++ ...RidesApplicationServiceTests.RecordRide.cs | 291 ++++++ .../RidesApplicationServiceTests.Shared.cs | 49 + .../RidesApplicationServiceTests.cs | 862 ------------------ .../Endpoints/RidesEndpointsTests.Edit.cs | 215 +++++ .../Endpoints/RidesEndpointsTests.History.cs | 117 +++ .../RidesEndpointsTests.RidePresets.cs | 150 +++ .../Endpoints/RidesEndpointsTests.cs | 458 +--------- .../TestSupport/TestFactories.cs | 16 + .../Dashboard/GetAdvancedDashboardService.cs | 7 +- .../Dashboard/GetDashboardService.cs | 4 +- .../Dashboard/GetYearStatsDashboardService.cs | 10 +- .../Rides/GetRideHistoryService.cs | 7 +- .../Application/Rides/WeatherLookupService.cs | 9 +- .../Endpoints/DashboardEndpoints.cs | 3 +- src/BikeTracking.Api/Program.cs | 1 + 23 files changed, 1500 insertions(+), 1385 deletions(-) create mode 100644 src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.Edit.cs create mode 100644 src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.History.cs create mode 100644 src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.RecordRide.cs create mode 100644 src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.Shared.cs delete mode 100644 src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs create mode 100644 src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.Edit.cs create mode 100644 src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.History.cs create mode 100644 src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.RidePresets.cs diff --git a/docs/agent/testing-and-quality-gates.md b/docs/agent/testing-and-quality-gates.md index 6892f27..134a574 100644 --- a/docs/agent/testing-and-quality-gates.md +++ b/docs/agent/testing-and-quality-gates.md @@ -27,5 +27,15 @@ Mandatory test and validation requirements before implementation and merge. - Run all impacted commands above - `cd src/BikeTracking.Frontend && npm run test:e2e` +## Backend Test Conventions +- No mocking framework (Moq/NSubstitute) is used in `BikeTracking.Api.Tests`. Use real objects, + the EF Core in-memory provider (`TestFactories.CreateDbContext`), and in-process + `WebApplication`/`TestServer` hosts instead. This avoids mock-tautology and over-specified + mock-interaction anti-patterns and keeps tests coupled to behavior, not implementation. +- Inject `TimeProvider` (registered as `TimeProvider.System` in `Program.cs`) into any service + that needs "now"/"today" instead of calling `DateTime.Now`/`DateTime.UtcNow` directly. Tests + pass `TestSupport.FakeTimeProvider` with a fixed instant so calendar-boundary logic (e.g. + "this week"/"this month") never depends on wall-clock time. + ## PR Gate - E2E tests are required for every PR. diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs index e67d86b..4b55858 100644 --- a/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetAdvancedDashboardServiceTests.cs @@ -1,6 +1,7 @@ using BikeTracking.Api.Application.Dashboard; using BikeTracking.Api.Infrastructure.Persistence; using BikeTracking.Api.Infrastructure.Persistence.Entities; +using BikeTracking.Api.Tests.TestSupport; using Microsoft.EntityFrameworkCore; namespace BikeTracking.Api.Tests.Application.Dashboard; @@ -37,7 +38,7 @@ public async Task GetAdvancedDashboardService_WithRidesInMultipleYears_ReturnsCo ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); // 20 miles / 20 mpg = 1 gallon + 10 miles / 10 mpg = 1 gallon = 2 total @@ -79,7 +80,7 @@ public async Task GetAdvancedDashboardService_WithRideMissingGasPrice_FlagsFuelC ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); Assert.True(result.SavingsWindows.AllTime.FuelCostEstimated); @@ -104,7 +105,7 @@ public async Task GetAdvancedDashboardService_UserWithNoMpgSetting_ReturnsMpgRem ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); Assert.True(result.Reminders.MpgReminderRequired); @@ -129,7 +130,7 @@ public async Task GetAdvancedDashboardService_UserWithNoMileageRateSetting_Retur ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); Assert.False(result.Reminders.MpgReminderRequired); @@ -171,7 +172,7 @@ public async Task GetAdvancedDashboardService_WithRidesInMultipleWindows_Returns ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); // Weekly: only the current-week ride (1 gallon) @@ -200,7 +201,7 @@ public async Task GetAdvancedDashboardService_PartialMonthRides_HandlesZeroDivis ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); // Zero MPG rides should contribute null (not throw) @@ -216,7 +217,11 @@ public async Task GetAdvancedDashboardService_RideThisWeek_ConsistencySuggestion using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "Consistency Rider"); - var weekStart = DateTime.Now.Date.AddDays(-(((int)DateTime.Now.DayOfWeek - 1 + 7) % 7)); + // Fixed reference "now" (a Wednesday) removes dependency on wall-clock time so this + // test can't flip near a real week boundary. + var fixedNow = new DateTime(2026, 1, 14, 12, 0, 0, DateTimeKind.Utc); + var timeProvider = new FakeTimeProvider(fixedNow); + var weekStart = fixedNow.Date.AddDays(-(((int)fixedNow.DayOfWeek - 1 + 7) % 7)); dbContext.Rides.Add( new RideEntity @@ -229,7 +234,7 @@ public async Task GetAdvancedDashboardService_RideThisWeek_ConsistencySuggestion ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, timeProvider); var result = await service.GetAsync(rider.UserId); var consistency = result.Suggestions.Single(s => s.SuggestionKey == "consistency"); @@ -255,7 +260,7 @@ public async Task GetAdvancedDashboardService_CombinedSavingsExceed50_MilestoneS ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); var milestone = result.Suggestions.Single(s => s.SuggestionKey == "milestone"); @@ -279,7 +284,7 @@ public async Task GetAdvancedDashboardService_LastRideMoreThan7DaysAgo_ComebackS ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); var comeback = result.Suggestions.Single(s => s.SuggestionKey == "comeback"); @@ -294,7 +299,7 @@ public async Task GetAdvancedDashboardService_UserWithNoRides_ReturnsZeroValuesG using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "No Rides Rider"); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); Assert.Equal(0, result.SavingsWindows.AllTime.RideCount); @@ -316,7 +321,7 @@ public async Task GetAdvancedDashboardService_NoSettings_BothReminderFlagsSet() using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "NoSettings Rider"); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); // No UserSettings row means both nulls @@ -330,7 +335,7 @@ public async Task GetAdvancedDashboardService_ResponseIncludesAllThreeSuggestion using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "AllSuggestions Rider"); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); Assert.Equal(3, result.Suggestions.Count); @@ -357,7 +362,7 @@ public async Task GetAdvancedDashboardService_MileageRateSavings_ComputedCorrect ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); // 10 miles × $0.67 = $6.70 @@ -412,7 +417,7 @@ public async Task GetAdvancedDashboardService_WithExpensesInWindow_IncludesExpen ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); Assert.Equal(50m, result.SavingsWindows.Monthly.TotalExpenses); @@ -450,7 +455,7 @@ public async Task GetAdvancedDashboardService_WithExpenses_NetSavingsIsCombinedM ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); // 100 miles × $0.67 = $67 combined savings - $30 expenses = $37 net @@ -489,7 +494,7 @@ public async Task GetAdvancedDashboardService_ExpensesExceedSavings_NetSavingsIs ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); // Net savings should be negative: $6.70 - $20 = -$13.30 @@ -535,7 +540,7 @@ public async Task GetAdvancedDashboardService_WithOilChangePrice_IncludesWindowe ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); // 3200 miles total — crosses one 3000-mile interval → 1 oil change × $40 = $40 @@ -570,7 +575,7 @@ public async Task GetAdvancedDashboardService_WithNoOilChangePrice_OilChangeSavi ); await dbContext.SaveChangesAsync(); - var service = new GetAdvancedDashboardService(dbContext); + var service = new GetAdvancedDashboardService(dbContext, TimeProvider.System); var result = await service.GetAsync(rider.UserId); Assert.Null(result.SavingsWindows.AllTime.OilChangeSavings); diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs index 61b370d..41da19e 100644 --- a/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetDashboardServiceTests.cs @@ -68,7 +68,7 @@ public async Task GetDashboardService_UsesRideSnapshotsForSavings_WhenCurrentSet ); await dbContext.SaveChangesAsync(); - var service = new GetDashboardService(dbContext); + var service = new GetDashboardService(dbContext, TimeProvider.System); var dashboard = await service.GetAsync(rider.UserId); Assert.Equal(5m, dashboard.Totals.MoneySaved.MileageRateSavings); @@ -101,7 +101,7 @@ public async Task GetDashboardService_ExcludesLegacyRideWithoutSnapshot_FromSavi ); await dbContext.SaveChangesAsync(); - var service = new GetDashboardService(dbContext); + var service = new GetDashboardService(dbContext, TimeProvider.System); var dashboard = await service.GetAsync(rider.UserId); Assert.Null(dashboard.Totals.MoneySaved.MileageRateSavings); @@ -149,7 +149,7 @@ public async Task GetDashboardService_IncludesOptionalMetricValues_WhenDataIsAva ); await dbContext.SaveChangesAsync(); - var service = new GetDashboardService(dbContext); + var service = new GetDashboardService(dbContext, TimeProvider.System); var dashboard = await service.GetAsync(rider.UserId); var gallonsSuggestion = dashboard.Suggestions.Single(metric => @@ -178,7 +178,7 @@ public async Task GetDashboardService_ExpenseSummary_WithNoExpenses_ReturnsZeroT dbContext.Users.Add(rider); await dbContext.SaveChangesAsync(); - var service = new GetDashboardService(dbContext); + var service = new GetDashboardService(dbContext, TimeProvider.System); var dashboard = await service.GetAsync(rider.UserId); Assert.NotNull(dashboard.Totals.ExpenseSummary); @@ -234,7 +234,7 @@ public async Task GetDashboardService_ExpenseSummary_SumsNonDeletedExpenses() ); await dbContext.SaveChangesAsync(); - var service = new GetDashboardService(dbContext); + var service = new GetDashboardService(dbContext, TimeProvider.System); var dashboard = await service.GetAsync(rider.UserId); Assert.Equal(40.50m, dashboard.Totals.ExpenseSummary.TotalManualExpenses); @@ -294,7 +294,7 @@ public async Task GetDashboardService_ExpenseSummary_WithOilChangePrice_Calculat ); await dbContext.SaveChangesAsync(); - var service = new GetDashboardService(dbContext); + var service = new GetDashboardService(dbContext, TimeProvider.System); var dashboard = await service.GetAsync(rider.UserId); Assert.Equal(80m, dashboard.Totals.ExpenseSummary.TotalManualExpenses); @@ -327,7 +327,7 @@ public async Task GetDashboardService_ExpenseSummary_WithNoOilChangePrice_OilSav ); await dbContext.SaveChangesAsync(); - var service = new GetDashboardService(dbContext); + var service = new GetDashboardService(dbContext, TimeProvider.System); var dashboard = await service.GetAsync(rider.UserId); Assert.Equal(0m, dashboard.Totals.ExpenseSummary.TotalManualExpenses); diff --git a/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs index 230aa02..623ba2c 100644 --- a/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Dashboard/GetYearStatsDashboardServiceTests.cs @@ -56,7 +56,7 @@ public async Task GetAsync_YearWithFullTwelveMonthsOfRides_ProducesCorrectPerMon } await dbContext.SaveChangesAsync(); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAsync(rider.UserId, 2025); Assert.Equal(2025, response.Year); @@ -94,7 +94,7 @@ public async Task GetAsync_InProgressCurrentYearWithPartialData_ZeroFillsElapsed ); await dbContext.SaveChangesAsync(); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAsync(rider.UserId, currentYear); Assert.True(response.HasDataForYear); @@ -116,7 +116,7 @@ public async Task GetAsync_YearWithZeroRides_ReturnsHasDataForYearFalse_NoExcept using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "Zero Rides Rider"); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAsync(rider.UserId, 2019); Assert.False(response.HasDataForYear); @@ -165,7 +165,7 @@ public async Task GetAsync_UsesRideSnapshotsForSavings_WhenCurrentSettingsChange ); await dbContext.SaveChangesAsync(); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAsync(rider.UserId, 2022); var june = response.SavingsByMonth[5]; @@ -202,7 +202,7 @@ public async Task GetAsync_FiltersDifficultyAndWindResistance_ToRequestedYearOnl ); await dbContext.SaveChangesAsync(); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAsync(rider.UserId, 2025); Assert.True(response.Difficulty.HasData); @@ -229,7 +229,7 @@ public async Task GetAsync_RidesWithoutDifficultyOrWindData_YieldsPartialEmptySt ); await dbContext.SaveChangesAsync(); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAsync(rider.UserId, 2025); Assert.True(response.HasDataForYear); @@ -288,7 +288,7 @@ public async Task GetAsync_YearWithRidesAndExpenses_ComputesTotalsSection() ); await dbContext.SaveChangesAsync(); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAsync(rider.UserId, 2025); Assert.Equal(150m, response.Totals.TotalMiles); @@ -302,7 +302,7 @@ public async Task GetAsync_YearWithZeroRides_TotalsAreZeroOrNull() using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "Totals Zero Rider"); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAsync(rider.UserId, 2019); Assert.Equal(0m, response.Totals.TotalMiles); @@ -348,7 +348,7 @@ public async Task GetAvailableYearsAsync_RiderWithRidesInMultipleYears_ReturnsDe ); await dbContext.SaveChangesAsync(); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAvailableYearsAsync(rider.UserId); Assert.Equal([2025, 2024, 2023], response.Years); @@ -360,7 +360,7 @@ public async Task GetAvailableYearsAsync_RiderWithZeroRides_ReturnsCurrentYearFa using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "No Rides Rider"); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAvailableYearsAsync(rider.UserId); Assert.Equal([DateTime.Now.Year], response.Years); @@ -383,7 +383,7 @@ public async Task GetAvailableYearsAsync_RiderWithRidesInOnlyOneYear_ReturnsThat ); await dbContext.SaveChangesAsync(); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAvailableYearsAsync(rider.UserId); Assert.Equal([2021], response.Years); @@ -396,7 +396,7 @@ public async Task GetAsync_YearBoundaryValue1900_IsAccepted(int year) using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "Boundary Rider 1900"); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAsync(rider.UserId, year); Assert.Equal(year, response.Year); @@ -408,7 +408,7 @@ public async Task GetAsync_YearBoundaryValueCurrentYearPlusOne_IsAccepted() using var dbContext = CreateDbContext(); var rider = await CreateRiderAsync(dbContext, "Boundary Rider Next Year"); - var service = new GetYearStatsDashboardService(dbContext); + var service = new GetYearStatsDashboardService(dbContext, TimeProvider.System); var response = await service.GetAsync(rider.UserId, DateTime.Now.Year + 1); Assert.Equal(DateTime.Now.Year + 1, response.Year); diff --git a/src/BikeTracking.Api.Tests/Application/Imports/CsvRideImportServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Imports/CsvRideImportServiceTests.cs index cacd43b..d455393 100644 --- a/src/BikeTracking.Api.Tests/Application/Imports/CsvRideImportServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Imports/CsvRideImportServiceTests.cs @@ -69,17 +69,17 @@ public void TokenBucketThrottle_ConsumeAndReleaseTokens() public async Task TokenBucketThrottle_WaitsWhenTokensExhausted() { var throttle = new SemaphoreSlim(1); - var releaseTask = Task.Delay(100).ContinueWith(_ => throttle.Release()); - - var sw = System.Diagnostics.Stopwatch.StartNew(); await throttle.WaitAsync(); var waitTask = throttle.WaitAsync(); - await releaseTask; + + // No token has been released yet, so the waiter must still be pending. + Assert.False(waitTask.IsCompleted); + + throttle.Release(); await waitTask; - sw.Stop(); - Assert.True(sw.ElapsedMilliseconds >= 50); + Assert.True(waitTask.IsCompletedSuccessfully); } [Fact] diff --git a/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs b/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs index f2943fa..826c161 100644 --- a/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs +++ b/src/BikeTracking.Api.Tests/Application/Rides/WeatherLookupServiceTests.cs @@ -4,6 +4,7 @@ using BikeTracking.Api.Application.Rides; using BikeTracking.Api.Infrastructure.Persistence; using BikeTracking.Api.Infrastructure.Persistence.Entities; +using BikeTracking.Api.Tests.TestSupport; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -75,7 +76,8 @@ public async Task GetOrFetchAsync_CacheHit_DoesNotCallHttp() context, factory, config, - NullLogger.Instance + NullLogger.Instance, + TimeProvider.System ); var result = await service.GetOrFetchAsync(40.7128m, -74.0060m, lookupHour); @@ -113,7 +115,8 @@ public async Task GetOrFetchAsync_SecondCall_UsesCacheAndCallsHttpOnce() context, factory, config, - NullLogger.Instance + NullLogger.Instance, + TimeProvider.System ); var lookupTime = new DateTime(2026, 4, 2, 9, 34, 0, DateTimeKind.Utc); @@ -159,14 +162,21 @@ public async Task GetOrFetchAsync_ForecastRequest_DoesNotCombinePastDaysWithExpl ); var config = new ConfigurationBuilder().AddInMemoryCollection().Build(); + // Fixed reference "now" removes dependency on wall-clock time so the forecast/archive + // boundary decision (daysDiff > 92) can't shift if the test happens to run near + // midnight UTC. + var fixedNow = new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero); + var timeProvider = new FakeTimeProvider(fixedNow); + var service = new OpenMeteoWeatherLookupService( context, factory, config, - NullLogger.Instance + NullLogger.Instance, + timeProvider ); - var lookupDate = DateTime.UtcNow.AddDays(-5).Date; + var lookupDate = fixedNow.UtcDateTime.AddDays(-5).Date; var lookupTime = new DateTime( lookupDate.Year, lookupDate.Month, @@ -221,7 +231,8 @@ public async Task GetOrFetchAsync_ArchiveRequest_UsesArchivePathWithExplicitDate context, factory, config, - NullLogger.Instance + NullLogger.Instance, + TimeProvider.System ); var lookupTime = new DateTime(2025, 12, 1, 9, 34, 0, DateTimeKind.Utc); @@ -285,7 +296,8 @@ public async Task GetOrFetchAsync_AfterServiceRestart_UsesPersistedCacheWithoutH restartedContext, factory, config, - NullLogger.Instance + NullLogger.Instance, + TimeProvider.System ); var result = await restartedService.GetOrFetchAsync(37.7749m, -122.4194m, lookupHour); @@ -336,7 +348,8 @@ public async Task GetOrFetchAsync_WhenDuplicateKeyDuringSuccessCacheInsert_DoesN context, factory, config, - NullLogger.Instance + NullLogger.Instance, + TimeProvider.System ); var result = await service.GetOrFetchAsync(40.7128m, -74.0060m, lookupHour); diff --git a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.Edit.cs b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.Edit.cs new file mode 100644 index 0000000..821eba2 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.Edit.cs @@ -0,0 +1,332 @@ +using BikeTracking.Api.Application.Rides; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace BikeTracking.Api.Tests.Application; + +public sealed partial class RidesApplicationServiceTests +{ + [Fact] + public async Task EditRideService_WithValidRequest_UpdatesRideVersion() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Kara", + NormalizedName = "kara", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + + var ride = new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = DateTime.Now.AddHours(-1), + Miles = 9.5m, + RideMinutes = 40, + Temperature = 64m, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + }; + context.Rides.Add(ride); + await context.SaveChangesAsync(); + + var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var logger = loggerFactory.CreateLogger(); + var service = new EditRideService(context, new StubWeatherLookupService(), logger); + + var request = new EditRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 12m, + RideMinutes: 48, + Temperature: 66m, + ExpectedVersion: 1 + ); + + var result = await service.ExecuteAsync(user.UserId, ride.Id, request); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Response); + + Assert.Equal(ride.Id, result.Response!.RideId); + Assert.Equal(2, result.Response.NewVersion); + + var updatedRide = await context.Rides.SingleAsync(r => r.Id == ride.Id); + Assert.Equal(12m, updatedRide.Miles); + Assert.Equal(48, updatedRide.RideMinutes); + Assert.Equal(66m, updatedRide.Temperature); + Assert.Equal(2, updatedRide.Version); + } + + [Fact] + public async Task GetRideHistoryService_RecalculatesSummariesAfterRideEdit() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Lena", + NormalizedName = "lena", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + + var rideDate = DateTime.Now.Date.AddHours(8); + var ride = new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = rideDate, + Miles = 5m, + RideMinutes = 30, + Temperature = 60m, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + }; + context.Rides.Add(ride); + await context.SaveChangesAsync(); + + var historyService = new GetRideHistoryService(context, TimeProvider.System); + var beforeEdit = await historyService.GetRideHistoryAsync(user.UserId, null, null); + Assert.Equal(5m, beforeEdit.Summaries.AllTime.Miles); + Assert.Equal(5m, beforeEdit.FilteredTotal.Miles); + + var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var editLogger = loggerFactory.CreateLogger(); + var editService = new EditRideService(context, new StubWeatherLookupService(), editLogger); + + var editResult = await editService.ExecuteAsync( + user.UserId, + ride.Id, + new EditRideRequest( + RideDateTimeLocal: rideDate, + Miles: 9.5m, + RideMinutes: 34, + Temperature: 62m, + ExpectedVersion: 1 + ) + ); + + Assert.True(editResult.IsSuccess); + + var afterEdit = await historyService.GetRideHistoryAsync(user.UserId, null, null); + Assert.Equal(9.5m, afterEdit.Summaries.AllTime.Miles); + Assert.Equal(9.5m, afterEdit.Summaries.ThisMonth.Miles); + Assert.Equal(9.5m, afterEdit.FilteredTotal.Miles); + Assert.Single(afterEdit.Rides); + Assert.Equal(9.5m, afterEdit.Rides[0].Miles); + } + + [Fact] + public async Task RecordRideService_WhenUserSuppliesWeather_UsesUserValuesOverFetchedData() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Mina", + NormalizedName = "mina", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + context.UserSettings.Add( + new UserSettingsEntity + { + UserId = user.UserId, + Latitude = 40.71m, + Longitude = -74.01m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var weatherLookup = new TrackingWeatherLookupService( + new WeatherData(72m, 15m, 320, 55, 80, "rain") + ); + var service = new RecordRideService( + context, + weatherLookup, + NullLogger.Instance + ); + + var (_, payload) = await service.ExecuteAsync( + user.UserId, + new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 10m, + RideMinutes: 35, + Temperature: 66m, + WindSpeedMph: 9m, + WindDirectionDeg: 260, + RelativeHumidityPercent: 60, + CloudCoverPercent: 45, + PrecipitationType: "snow", + WeatherUserOverridden: false + ) + ); + + var persistedRide = await context.Rides.OrderByDescending(r => r.Id).FirstAsync(); + Assert.Equal(1, weatherLookup.CallCount); + Assert.Equal(66m, persistedRide.Temperature); + Assert.Equal(9m, persistedRide.WindSpeedMph); + Assert.Equal(260, persistedRide.WindDirectionDeg); + Assert.Equal(60, persistedRide.RelativeHumidityPercent); + Assert.Equal(45, persistedRide.CloudCoverPercent); + Assert.Equal("snow", persistedRide.PrecipitationType); + Assert.False(persistedRide.WeatherUserOverridden); + + Assert.Equal(66m, payload.Temperature); + Assert.Equal(9m, payload.WindSpeedMph); + Assert.Equal(260, payload.WindDirectionDeg); + Assert.Equal("snow", payload.PrecipitationType); + } + + [Fact] + public async Task EditRideService_WhenTimestampUnchanged_DoesNotRefetchWeather() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Nora", + NormalizedName = "nora", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var originalDate = DateTime.Now.AddHours(-2); + var ride = new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = originalDate, + Miles = 12m, + RideMinutes = 40, + Temperature = 61m, + WindSpeedMph = 8m, + WindDirectionDeg = 250, + RelativeHumidityPercent = 63, + CloudCoverPercent = 30, + PrecipitationType = "rain", + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + }; + context.Rides.Add(ride); + await context.SaveChangesAsync(); + + var weatherLookup = new TrackingWeatherLookupService( + new WeatherData(80m, 20m, 300, 45, 10, "snow") + ); + var service = new EditRideService( + context, + weatherLookup, + NullLogger.Instance + ); + + var result = await service.ExecuteAsync( + user.UserId, + ride.Id, + new EditRideRequest( + RideDateTimeLocal: originalDate, + Miles: 12.2m, + RideMinutes: 41, + Temperature: null, + ExpectedVersion: 1, + WindSpeedMph: null, + WindDirectionDeg: null, + RelativeHumidityPercent: null, + CloudCoverPercent: null, + PrecipitationType: null, + WeatherUserOverridden: false + ) + ); + + Assert.True(result.IsSuccess); + Assert.Equal(0, weatherLookup.CallCount); + + var persistedRide = await context.Rides.SingleAsync(r => r.Id == ride.Id); + Assert.Equal(61m, persistedRide.Temperature); + Assert.Equal(8m, persistedRide.WindSpeedMph); + Assert.Equal(250, persistedRide.WindDirectionDeg); + Assert.Equal("rain", persistedRide.PrecipitationType); + } + + [Fact] + public async Task EditRideService_RefreshesSnapshotFields_FromCurrentSettings() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Snapshot Edit Rider", + NormalizedName = "snapshot edit rider", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + context.UserSettings.Add( + new UserSettingsEntity + { + UserId = user.UserId, + AverageCarMpg = 32m, + MileageRateCents = 65m, + YearlyGoalMiles = 1800m, + OilChangePrice = 70m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var ride = new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = DateTime.Now.AddDays(-1), + Miles = 8m, + RideMinutes = 28, + GasPricePerGallon = 3.49m, + SnapshotAverageCarMpg = 25m, + SnapshotMileageRateCents = 50m, + SnapshotYearlyGoalMiles = 1200m, + SnapshotOilChangePrice = 55m, + Version = 1, + CreatedAtUtc = DateTime.UtcNow, + }; + context.Rides.Add(ride); + await context.SaveChangesAsync(); + + var service = new EditRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + + var result = await service.ExecuteAsync( + user.UserId, + ride.Id, + new EditRideRequest( + RideDateTimeLocal: ride.RideDateTimeLocal, + Miles: 9m, + RideMinutes: 31, + Temperature: 60m, + GasPricePerGallon: 3.59m, + ExpectedVersion: 1 + ) + ); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.EventPayload); + + var updatedRide = await context.Rides.SingleAsync(entity => entity.Id == ride.Id); + Assert.Equal(32m, updatedRide.SnapshotAverageCarMpg); + Assert.Equal(65m, updatedRide.SnapshotMileageRateCents); + Assert.Equal(1800m, updatedRide.SnapshotYearlyGoalMiles); + Assert.Equal(70m, updatedRide.SnapshotOilChangePrice); + + Assert.Equal(32m, result.EventPayload!.SnapshotAverageCarMpg); + Assert.Equal(65m, result.EventPayload.SnapshotMileageRateCents); + Assert.Equal(1800m, result.EventPayload.SnapshotYearlyGoalMiles); + Assert.Equal(70m, result.EventPayload.SnapshotOilChangePrice); + } +} diff --git a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.History.cs b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.History.cs new file mode 100644 index 0000000..d5946d2 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.History.cs @@ -0,0 +1,222 @@ +using BikeTracking.Api.Application.Rides; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Tests.Application; + +public sealed partial class RidesApplicationServiceTests +{ + // History service tests + + [Fact] + public async Task GetRideHistoryService_WithRides_ReturnsSummariesAndRows() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Frank", + NormalizedName = "frank", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + + // Add rides with one in prior month so thisMonth assertions stay deterministic. + var today = DateTime.Now; + var previousMonth = today.AddMonths(-1); + context.Rides.AddRange( + new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = previousMonth, + Miles = 10m, + CreatedAtUtc = DateTime.UtcNow, + }, + new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = today, + Miles = 5m, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var service = new GetRideHistoryService(context, TimeProvider.System); + + var result = await service.GetRideHistoryAsync(user.UserId, null, null); + + Assert.NotNull(result); + Assert.NotNull(result.Summaries); + Assert.Equal(15m, result.Summaries.AllTime.Miles); + Assert.Equal(2, result.Summaries.AllTime.RideCount); + Assert.Equal(5m, result.Summaries.ThisMonth.Miles); + Assert.Equal(1, result.Summaries.ThisMonth.RideCount); + Assert.Equal(2, result.Rides.Count); + Assert.Equal(15m, result.FilteredTotal.Miles); + } + + [Fact] + public async Task GetRideHistoryService_WithoutRides_ReturnsZeroSummaries() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Grace", + NormalizedName = "grace", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new GetRideHistoryService(context, TimeProvider.System); + + var result = await service.GetRideHistoryAsync(user.UserId, null, null); + + Assert.NotNull(result); + Assert.Empty(result.Rides); + Assert.Equal(0, result.TotalRows); + Assert.Equal(0m, result.Summaries.AllTime.Miles); + Assert.Equal(0, result.Summaries.AllTime.RideCount); + Assert.Equal(0m, result.FilteredTotal.Miles); + } + + [Fact] + public async Task GetRideHistoryService_WithDateRangeFilter_ReturnsFilteredRows() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Henry", + NormalizedName = "henry", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + + var today = DateTime.Now; + var dateOnlyToday = DateOnly.FromDateTime(today); + var dateOnlyWeekAgo = dateOnlyToday.AddDays(-7); + + context.Rides.AddRange( + new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = dateOnlyWeekAgo.ToDateTime(TimeOnly.MinValue), + Miles = 10m, + CreatedAtUtc = DateTime.UtcNow, + }, + new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = today, + Miles = 5m, + CreatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var service = new GetRideHistoryService(context, TimeProvider.System); + + var result = await service.GetRideHistoryAsync( + user.UserId, + dateOnlyToday.AddDays(-1), + dateOnlyToday + ); + + Assert.Single(result.Rides); + Assert.Equal(5m, result.FilteredTotal.Miles); + Assert.Equal(1, result.TotalRows); + } + + [Fact] + public async Task GetRideHistoryService_WithInvalidDateRange_Throws() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Ivy", + NormalizedName = "ivy", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new GetRideHistoryService(context, TimeProvider.System); + + await Assert.ThrowsAsync(() => + service.GetRideHistoryAsync( + user.UserId, + DateOnly.FromDateTime(DateTime.Now), + DateOnly.FromDateTime(DateTime.Now.AddDays(-1)) + ) + ); + } + + [Fact] + public async Task GetRideHistoryService_WithPageSize_RespectsPagination() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Jack", + NormalizedName = "jack", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + + // Add 5 rides + for (int i = 0; i < 5; i++) + { + context.Rides.Add( + new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = DateTime.Now.AddDays(-i), + Miles = (i + 1) * 1m, + CreatedAtUtc = DateTime.UtcNow, + } + ); + } + await context.SaveChangesAsync(); + + var service = new GetRideHistoryService(context, TimeProvider.System); + + var result = await service.GetRideHistoryAsync(user.UserId, null, null, pageSize: 2); + + Assert.Equal(2, result.Rides.Count); + Assert.Equal(1, result.Page); + Assert.Equal(2, result.PageSize); + Assert.Equal(5, result.TotalRows); + } + + [Fact] + public async Task GetRideHistoryService_WithRideNote_ProjectsNoteInHistoryRow() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Note History", + NormalizedName = "note history", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + + context.Rides.Add( + new RideEntity + { + RiderId = user.UserId, + RideDateTimeLocal = DateTime.Now, + Miles = 6.4m, + Notes = "Strong crosswind near downtown bridge.", + CreatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var service = new GetRideHistoryService(context, TimeProvider.System); + var result = await service.GetRideHistoryAsync(user.UserId, null, null); + + Assert.Single(result.Rides); + Assert.Equal("Strong crosswind near downtown bridge.", result.Rides[0].Note); + } +} diff --git a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.RecordRide.cs b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.RecordRide.cs new file mode 100644 index 0000000..9cf2bc8 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.RecordRide.cs @@ -0,0 +1,291 @@ +using BikeTracking.Api.Application.Rides; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace BikeTracking.Api.Tests.Application; + +public sealed partial class RidesApplicationServiceTests +{ + [Fact] + public async Task RecordRideService_WithValidRequest_PersistsRideAndCreatesEvent() + { + using var context = CreateDbContext(); + // Seed user + var user = new UserEntity + { + DisplayName = "Alice", + NormalizedName = "alice", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + var request = new RecordRideRequest(DateTime.Now, 10.5m, 45, 72m); + + var (rideId, eventPayload) = await service.ExecuteAsync(user.UserId, request); + + Assert.True(rideId > 0); + Assert.NotNull(eventPayload); + Assert.Equal(user.UserId, eventPayload.RiderId); + Assert.Equal(10.5m, eventPayload.Miles); + Assert.Equal(45, eventPayload.RideMinutes); + Assert.Equal(72m, eventPayload.Temperature); + + // Verify ride was persisted + var persistedRide = await context.Rides.FindAsync(rideId); + Assert.NotNull(persistedRide); + Assert.Equal(user.UserId, persistedRide.RiderId); + Assert.Equal(10.5m, persistedRide.Miles); + } + + [Fact] + public async Task RecordRideService_WithWeatherFields_PersistsWeatherAndEventPayload() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Weather Rider", + NormalizedName = "weather rider", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + var request = new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 12.5m, + RideMinutes: 41, + Temperature: 62m, + GasPricePerGallon: 3.1999m, + WindSpeedMph: 7.5m, + WindDirectionDeg: 250, + RelativeHumidityPercent: 60, + CloudCoverPercent: 45, + PrecipitationType: "rain", + WeatherUserOverridden: true + ); + + var (rideId, eventPayload) = await service.ExecuteAsync(user.UserId, request); + + var persistedRide = await context.Rides.FindAsync(rideId); + Assert.NotNull(persistedRide); + Assert.Equal(7.5m, persistedRide.WindSpeedMph); + Assert.Equal(250, persistedRide.WindDirectionDeg); + Assert.Equal(60, persistedRide.RelativeHumidityPercent); + Assert.Equal(45, persistedRide.CloudCoverPercent); + Assert.Equal("rain", persistedRide.PrecipitationType); + Assert.True(persistedRide.WeatherUserOverridden); + + Assert.Equal(7.5m, eventPayload.WindSpeedMph); + Assert.Equal(250, eventPayload.WindDirectionDeg); + Assert.Equal(60, eventPayload.RelativeHumidityPercent); + Assert.Equal(45, eventPayload.CloudCoverPercent); + Assert.Equal("rain", eventPayload.PrecipitationType); + Assert.True(eventPayload.WeatherUserOverridden); + } + + [Fact] + public async Task RecordRideService_WithValidNote_PersistsRideNoteAndEventPayloadNote() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Notes Rider", + NormalizedName = "notes rider", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + + var note = "Bridge detour this morning."; + var request = new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 8.2m, + RideMinutes: 32, + Temperature: 64m, + Note: note + ); + + var (rideId, eventPayload) = await service.ExecuteAsync(user.UserId, request); + + var persistedRide = await context.Rides.SingleAsync(ride => ride.Id == rideId); + Assert.Equal(note, persistedRide.Notes); + Assert.Equal(note, eventPayload.Note); + } + + [Fact] + public async Task RecordRideService_WithNoteLongerThanFiveHundredChars_ThrowsArgumentException() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Long Notes Rider", + NormalizedName = "long notes rider", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + + var tooLongNote = new string('n', 501); + var request = new RecordRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 7.1m, + RideMinutes: 28, + Temperature: 60m, + Note: tooLongNote + ); + + await Assert.ThrowsAsync(() => + service.ExecuteAsync(user.UserId, request) + ); + } + + [Fact] + public async Task RecordRideService_CapturesUserSettingsSnapshots_OnRideAndEventPayload() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Snapshot Rider", + NormalizedName = "snapshot rider", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + context.UserSettings.Add( + new UserSettingsEntity + { + UserId = user.UserId, + AverageCarMpg = 31.5m, + MileageRateCents = 67m, + YearlyGoalMiles = 2400m, + OilChangePrice = 79m, + UpdatedAtUtc = DateTime.UtcNow, + } + ); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + + var (rideId, eventPayload) = await service.ExecuteAsync( + user.UserId, + new RecordRideRequest(DateTime.Now, 11m, 30, 63m, 3.29m) + ); + + var persistedRide = await context.Rides.SingleAsync(ride => ride.Id == rideId); + Assert.Equal(31.5m, persistedRide.SnapshotAverageCarMpg); + Assert.Equal(67m, persistedRide.SnapshotMileageRateCents); + Assert.Equal(2400m, persistedRide.SnapshotYearlyGoalMiles); + Assert.Equal(79m, persistedRide.SnapshotOilChangePrice); + + Assert.Equal(31.5m, eventPayload.SnapshotAverageCarMpg); + Assert.Equal(67m, eventPayload.SnapshotMileageRateCents); + Assert.Equal(2400m, eventPayload.SnapshotYearlyGoalMiles); + Assert.Equal(79m, eventPayload.SnapshotOilChangePrice); + } + + [Fact] + public async Task RecordRideService_ValidatesMillesGreaterThanZero() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Bob", + NormalizedName = "bob", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + var request = new RecordRideRequest(DateTime.Now, 0m); + + await Assert.ThrowsAsync(() => + service.ExecuteAsync(user.UserId, request) + ); + } + + [Fact] + public async Task RecordRideService_ValidatesRideMinutesGreaterThanZeroWhenProvided() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Charlie", + NormalizedName = "charlie", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + var request = new RecordRideRequest(DateTime.Now, 10m, -5); + + await Assert.ThrowsAsync(() => + service.ExecuteAsync(user.UserId, request) + ); + } + + [Fact] + public async Task RecordRideService_ValidatesMilesLessThanOrEqualToTwoHundred() + { + using var context = CreateDbContext(); + var user = new UserEntity + { + DisplayName = "Cara", + NormalizedName = "cara", + CreatedAtUtc = DateTime.UtcNow, + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var service = new RecordRideService( + context, + new StubWeatherLookupService(), + NullLogger.Instance + ); + var request = new RecordRideRequest(DateTime.Now, 201m); + + await Assert.ThrowsAsync(() => + service.ExecuteAsync(user.UserId, request) + ); + } +} diff --git a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.Shared.cs b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.Shared.cs new file mode 100644 index 0000000..81ff6e7 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.Shared.cs @@ -0,0 +1,49 @@ +using BikeTracking.Api.Application.Rides; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using BikeTracking.Api.Infrastructure.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace BikeTracking.Api.Tests.Application; + +public sealed partial class RidesApplicationServiceTests +{ + private static BikeTrackingDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new BikeTrackingDbContext(options); + } +} + +internal sealed class StubWeatherLookupService : IWeatherLookupService +{ + public Task GetOrFetchAsync( + decimal latitude, + decimal longitude, + DateTime dateTimeUtc, + string? apiKey = null, + CancellationToken cancellationToken = default + ) => Task.FromResult(null); +} + +internal sealed class TrackingWeatherLookupService(WeatherData? response) : IWeatherLookupService +{ + public int CallCount { get; private set; } + + public Task GetOrFetchAsync( + decimal latitude, + decimal longitude, + DateTime dateTimeUtc, + string? apiKey = null, + CancellationToken cancellationToken = default + ) + { + CallCount++; + return Task.FromResult(response); + } +} diff --git a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs b/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs deleted file mode 100644 index 2fa536e..0000000 --- a/src/BikeTracking.Api.Tests/Application/RidesApplicationServiceTests.cs +++ /dev/null @@ -1,862 +0,0 @@ -using BikeTracking.Api.Application.Rides; -using BikeTracking.Api.Contracts; -using BikeTracking.Api.Infrastructure.Persistence; -using BikeTracking.Api.Infrastructure.Persistence.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace BikeTracking.Api.Tests.Application; - -public sealed class RidesApplicationServiceTests -{ - [Fact] - public async Task RecordRideService_WithValidRequest_PersistsRideAndCreatesEvent() - { - using var context = CreateDbContext(); - // Seed user - var user = new UserEntity - { - DisplayName = "Alice", - NormalizedName = "alice", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var service = new RecordRideService( - context, - new StubWeatherLookupService(), - NullLogger.Instance - ); - var request = new RecordRideRequest(DateTime.Now, 10.5m, 45, 72m); - - var (rideId, eventPayload) = await service.ExecuteAsync(user.UserId, request); - - Assert.True(rideId > 0); - Assert.NotNull(eventPayload); - Assert.Equal(user.UserId, eventPayload.RiderId); - Assert.Equal(10.5m, eventPayload.Miles); - Assert.Equal(45, eventPayload.RideMinutes); - Assert.Equal(72m, eventPayload.Temperature); - - // Verify ride was persisted - var persistedRide = await context.Rides.FindAsync(rideId); - Assert.NotNull(persistedRide); - Assert.Equal(user.UserId, persistedRide.RiderId); - Assert.Equal(10.5m, persistedRide.Miles); - } - - [Fact] - public async Task RecordRideService_WithWeatherFields_PersistsWeatherAndEventPayload() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Weather Rider", - NormalizedName = "weather rider", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var service = new RecordRideService( - context, - new StubWeatherLookupService(), - NullLogger.Instance - ); - var request = new RecordRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 12.5m, - RideMinutes: 41, - Temperature: 62m, - GasPricePerGallon: 3.1999m, - WindSpeedMph: 7.5m, - WindDirectionDeg: 250, - RelativeHumidityPercent: 60, - CloudCoverPercent: 45, - PrecipitationType: "rain", - WeatherUserOverridden: true - ); - - var (rideId, eventPayload) = await service.ExecuteAsync(user.UserId, request); - - var persistedRide = await context.Rides.FindAsync(rideId); - Assert.NotNull(persistedRide); - Assert.Equal(7.5m, persistedRide.WindSpeedMph); - Assert.Equal(250, persistedRide.WindDirectionDeg); - Assert.Equal(60, persistedRide.RelativeHumidityPercent); - Assert.Equal(45, persistedRide.CloudCoverPercent); - Assert.Equal("rain", persistedRide.PrecipitationType); - Assert.True(persistedRide.WeatherUserOverridden); - - Assert.Equal(7.5m, eventPayload.WindSpeedMph); - Assert.Equal(250, eventPayload.WindDirectionDeg); - Assert.Equal(60, eventPayload.RelativeHumidityPercent); - Assert.Equal(45, eventPayload.CloudCoverPercent); - Assert.Equal("rain", eventPayload.PrecipitationType); - Assert.True(eventPayload.WeatherUserOverridden); - } - - [Fact] - public async Task RecordRideService_WithValidNote_PersistsRideNoteAndEventPayloadNote() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Notes Rider", - NormalizedName = "notes rider", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var service = new RecordRideService( - context, - new StubWeatherLookupService(), - NullLogger.Instance - ); - - var note = "Bridge detour this morning."; - var request = new RecordRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 8.2m, - RideMinutes: 32, - Temperature: 64m, - Note: note - ); - - var (rideId, eventPayload) = await service.ExecuteAsync(user.UserId, request); - - var persistedRide = await context.Rides.SingleAsync(ride => ride.Id == rideId); - Assert.Equal(note, persistedRide.Notes); - Assert.Equal(note, eventPayload.Note); - } - - [Fact] - public async Task RecordRideService_WithNoteLongerThanFiveHundredChars_ThrowsArgumentException() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Long Notes Rider", - NormalizedName = "long notes rider", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var service = new RecordRideService( - context, - new StubWeatherLookupService(), - NullLogger.Instance - ); - - var tooLongNote = new string('n', 501); - var request = new RecordRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 7.1m, - RideMinutes: 28, - Temperature: 60m, - Note: tooLongNote - ); - - await Assert.ThrowsAsync(() => - service.ExecuteAsync(user.UserId, request) - ); - } - - [Fact] - public async Task RecordRideService_CapturesUserSettingsSnapshots_OnRideAndEventPayload() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Snapshot Rider", - NormalizedName = "snapshot rider", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - context.UserSettings.Add( - new UserSettingsEntity - { - UserId = user.UserId, - AverageCarMpg = 31.5m, - MileageRateCents = 67m, - YearlyGoalMiles = 2400m, - OilChangePrice = 79m, - UpdatedAtUtc = DateTime.UtcNow, - } - ); - await context.SaveChangesAsync(); - - var service = new RecordRideService( - context, - new StubWeatherLookupService(), - NullLogger.Instance - ); - - var (rideId, eventPayload) = await service.ExecuteAsync( - user.UserId, - new RecordRideRequest(DateTime.Now, 11m, 30, 63m, 3.29m) - ); - - var persistedRide = await context.Rides.SingleAsync(ride => ride.Id == rideId); - Assert.Equal(31.5m, persistedRide.SnapshotAverageCarMpg); - Assert.Equal(67m, persistedRide.SnapshotMileageRateCents); - Assert.Equal(2400m, persistedRide.SnapshotYearlyGoalMiles); - Assert.Equal(79m, persistedRide.SnapshotOilChangePrice); - - Assert.Equal(31.5m, eventPayload.SnapshotAverageCarMpg); - Assert.Equal(67m, eventPayload.SnapshotMileageRateCents); - Assert.Equal(2400m, eventPayload.SnapshotYearlyGoalMiles); - Assert.Equal(79m, eventPayload.SnapshotOilChangePrice); - } - - [Fact] - public async Task RecordRideService_ValidatesMillesGreaterThanZero() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Bob", - NormalizedName = "bob", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var service = new RecordRideService( - context, - new StubWeatherLookupService(), - NullLogger.Instance - ); - var request = new RecordRideRequest(DateTime.Now, 0m); - - await Assert.ThrowsAsync(() => - service.ExecuteAsync(user.UserId, request) - ); - } - - [Fact] - public async Task RecordRideService_ValidatesRideMinutesGreaterThanZeroWhenProvided() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Charlie", - NormalizedName = "charlie", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var service = new RecordRideService( - context, - new StubWeatherLookupService(), - NullLogger.Instance - ); - var request = new RecordRideRequest(DateTime.Now, 10m, -5); - - await Assert.ThrowsAsync(() => - service.ExecuteAsync(user.UserId, request) - ); - } - - [Fact] - public async Task RecordRideService_ValidatesMilesLessThanOrEqualToTwoHundred() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Cara", - NormalizedName = "cara", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var service = new RecordRideService( - context, - new StubWeatherLookupService(), - NullLogger.Instance - ); - var request = new RecordRideRequest(DateTime.Now, 201m); - - await Assert.ThrowsAsync(() => - service.ExecuteAsync(user.UserId, request) - ); - } - - // History service tests - - [Fact] - public async Task GetRideHistoryService_WithRides_ReturnsSummariesAndRows() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Frank", - NormalizedName = "frank", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - - // Add rides with one in prior month so thisMonth assertions stay deterministic. - var today = DateTime.Now; - var previousMonth = today.AddMonths(-1); - context.Rides.AddRange( - new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = previousMonth, - Miles = 10m, - CreatedAtUtc = DateTime.UtcNow, - }, - new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = today, - Miles = 5m, - CreatedAtUtc = DateTime.UtcNow, - } - ); - await context.SaveChangesAsync(); - - var service = new GetRideHistoryService(context); - - var result = await service.GetRideHistoryAsync(user.UserId, null, null); - - Assert.NotNull(result); - Assert.NotNull(result.Summaries); - Assert.Equal(15m, result.Summaries.AllTime.Miles); - Assert.Equal(2, result.Summaries.AllTime.RideCount); - Assert.Equal(5m, result.Summaries.ThisMonth.Miles); - Assert.Equal(1, result.Summaries.ThisMonth.RideCount); - Assert.Equal(2, result.Rides.Count); - Assert.Equal(15m, result.FilteredTotal.Miles); - } - - [Fact] - public async Task GetRideHistoryService_WithoutRides_ReturnsZeroSummaries() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Grace", - NormalizedName = "grace", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var service = new GetRideHistoryService(context); - - var result = await service.GetRideHistoryAsync(user.UserId, null, null); - - Assert.NotNull(result); - Assert.Empty(result.Rides); - Assert.Equal(0, result.TotalRows); - Assert.Equal(0m, result.Summaries.AllTime.Miles); - Assert.Equal(0, result.Summaries.AllTime.RideCount); - Assert.Equal(0m, result.FilteredTotal.Miles); - } - - [Fact] - public async Task GetRideHistoryService_WithDateRangeFilter_ReturnsFilteredRows() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Henry", - NormalizedName = "henry", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - - var today = DateTime.Now; - var dateOnlyToday = DateOnly.FromDateTime(today); - var dateOnlyWeekAgo = dateOnlyToday.AddDays(-7); - - context.Rides.AddRange( - new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = dateOnlyWeekAgo.ToDateTime(TimeOnly.MinValue), - Miles = 10m, - CreatedAtUtc = DateTime.UtcNow, - }, - new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = today, - Miles = 5m, - CreatedAtUtc = DateTime.UtcNow, - } - ); - await context.SaveChangesAsync(); - - var service = new GetRideHistoryService(context); - - var result = await service.GetRideHistoryAsync( - user.UserId, - dateOnlyToday.AddDays(-1), - dateOnlyToday - ); - - Assert.Single(result.Rides); - Assert.Equal(5m, result.FilteredTotal.Miles); - Assert.Equal(1, result.TotalRows); - } - - [Fact] - public async Task GetRideHistoryService_WithInvalidDateRange_Throws() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Ivy", - NormalizedName = "ivy", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var service = new GetRideHistoryService(context); - - await Assert.ThrowsAsync(() => - service.GetRideHistoryAsync( - user.UserId, - DateOnly.FromDateTime(DateTime.Now), - DateOnly.FromDateTime(DateTime.Now.AddDays(-1)) - ) - ); - } - - [Fact] - public async Task GetRideHistoryService_WithPageSize_RespectsPagination() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Jack", - NormalizedName = "jack", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - - // Add 5 rides - for (int i = 0; i < 5; i++) - { - context.Rides.Add( - new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = DateTime.Now.AddDays(-i), - Miles = (i + 1) * 1m, - CreatedAtUtc = DateTime.UtcNow, - } - ); - } - await context.SaveChangesAsync(); - - var service = new GetRideHistoryService(context); - - var result = await service.GetRideHistoryAsync(user.UserId, null, null, pageSize: 2); - - Assert.Equal(2, result.Rides.Count); - Assert.Equal(1, result.Page); - Assert.Equal(2, result.PageSize); - Assert.Equal(5, result.TotalRows); - } - - [Fact] - public async Task GetRideHistoryService_WithRideNote_ProjectsNoteInHistoryRow() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Note History", - NormalizedName = "note history", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - - context.Rides.Add( - new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = DateTime.Now, - Miles = 6.4m, - Notes = "Strong crosswind near downtown bridge.", - CreatedAtUtc = DateTime.UtcNow, - } - ); - await context.SaveChangesAsync(); - - var service = new GetRideHistoryService(context); - var result = await service.GetRideHistoryAsync(user.UserId, null, null); - - Assert.Single(result.Rides); - Assert.Equal("Strong crosswind near downtown bridge.", result.Rides[0].Note); - } - - [Fact] - public async Task EditRideService_WithValidRequest_UpdatesRideVersion() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Kara", - NormalizedName = "kara", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - - var ride = new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = DateTime.Now.AddHours(-1), - Miles = 9.5m, - RideMinutes = 40, - Temperature = 64m, - Version = 1, - CreatedAtUtc = DateTime.UtcNow, - }; - context.Rides.Add(ride); - await context.SaveChangesAsync(); - - var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); - var service = new EditRideService(context, new StubWeatherLookupService(), logger); - - var request = new EditRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 12m, - RideMinutes: 48, - Temperature: 66m, - ExpectedVersion: 1 - ); - - var result = await service.ExecuteAsync(user.UserId, ride.Id, request); - - Assert.True(result.IsSuccess); - Assert.NotNull(result.Response); - - Assert.Equal(ride.Id, result.Response!.RideId); - Assert.Equal(2, result.Response.NewVersion); - - var updatedRide = await context.Rides.SingleAsync(r => r.Id == ride.Id); - Assert.Equal(12m, updatedRide.Miles); - Assert.Equal(48, updatedRide.RideMinutes); - Assert.Equal(66m, updatedRide.Temperature); - Assert.Equal(2, updatedRide.Version); - } - - [Fact] - public async Task GetRideHistoryService_RecalculatesSummariesAfterRideEdit() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Lena", - NormalizedName = "lena", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - - var rideDate = DateTime.Now.Date.AddHours(8); - var ride = new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = rideDate, - Miles = 5m, - RideMinutes = 30, - Temperature = 60m, - Version = 1, - CreatedAtUtc = DateTime.UtcNow, - }; - context.Rides.Add(ride); - await context.SaveChangesAsync(); - - var historyService = new GetRideHistoryService(context); - var beforeEdit = await historyService.GetRideHistoryAsync(user.UserId, null, null); - Assert.Equal(5m, beforeEdit.Summaries.AllTime.Miles); - Assert.Equal(5m, beforeEdit.FilteredTotal.Miles); - - var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var editLogger = loggerFactory.CreateLogger(); - var editService = new EditRideService(context, new StubWeatherLookupService(), editLogger); - - var editResult = await editService.ExecuteAsync( - user.UserId, - ride.Id, - new EditRideRequest( - RideDateTimeLocal: rideDate, - Miles: 9.5m, - RideMinutes: 34, - Temperature: 62m, - ExpectedVersion: 1 - ) - ); - - Assert.True(editResult.IsSuccess); - - var afterEdit = await historyService.GetRideHistoryAsync(user.UserId, null, null); - Assert.Equal(9.5m, afterEdit.Summaries.AllTime.Miles); - Assert.Equal(9.5m, afterEdit.Summaries.ThisMonth.Miles); - Assert.Equal(9.5m, afterEdit.FilteredTotal.Miles); - Assert.Single(afterEdit.Rides); - Assert.Equal(9.5m, afterEdit.Rides[0].Miles); - } - - [Fact] - public async Task RecordRideService_WhenUserSuppliesWeather_UsesUserValuesOverFetchedData() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Mina", - NormalizedName = "mina", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - context.UserSettings.Add( - new UserSettingsEntity - { - UserId = user.UserId, - Latitude = 40.71m, - Longitude = -74.01m, - UpdatedAtUtc = DateTime.UtcNow, - } - ); - await context.SaveChangesAsync(); - - var weatherLookup = new TrackingWeatherLookupService( - new WeatherData(72m, 15m, 320, 55, 80, "rain") - ); - var service = new RecordRideService( - context, - weatherLookup, - NullLogger.Instance - ); - - var (_, payload) = await service.ExecuteAsync( - user.UserId, - new RecordRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 10m, - RideMinutes: 35, - Temperature: 66m, - WindSpeedMph: 9m, - WindDirectionDeg: 260, - RelativeHumidityPercent: 60, - CloudCoverPercent: 45, - PrecipitationType: "snow", - WeatherUserOverridden: false - ) - ); - - var persistedRide = await context.Rides.OrderByDescending(r => r.Id).FirstAsync(); - Assert.Equal(1, weatherLookup.CallCount); - Assert.Equal(66m, persistedRide.Temperature); - Assert.Equal(9m, persistedRide.WindSpeedMph); - Assert.Equal(260, persistedRide.WindDirectionDeg); - Assert.Equal(60, persistedRide.RelativeHumidityPercent); - Assert.Equal(45, persistedRide.CloudCoverPercent); - Assert.Equal("snow", persistedRide.PrecipitationType); - Assert.False(persistedRide.WeatherUserOverridden); - - Assert.Equal(66m, payload.Temperature); - Assert.Equal(9m, payload.WindSpeedMph); - Assert.Equal(260, payload.WindDirectionDeg); - Assert.Equal("snow", payload.PrecipitationType); - } - - [Fact] - public async Task EditRideService_WhenTimestampUnchanged_DoesNotRefetchWeather() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Nora", - NormalizedName = "nora", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - var originalDate = DateTime.Now.AddHours(-2); - var ride = new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = originalDate, - Miles = 12m, - RideMinutes = 40, - Temperature = 61m, - WindSpeedMph = 8m, - WindDirectionDeg = 250, - RelativeHumidityPercent = 63, - CloudCoverPercent = 30, - PrecipitationType = "rain", - Version = 1, - CreatedAtUtc = DateTime.UtcNow, - }; - context.Rides.Add(ride); - await context.SaveChangesAsync(); - - var weatherLookup = new TrackingWeatherLookupService( - new WeatherData(80m, 20m, 300, 45, 10, "snow") - ); - var service = new EditRideService( - context, - weatherLookup, - NullLogger.Instance - ); - - var result = await service.ExecuteAsync( - user.UserId, - ride.Id, - new EditRideRequest( - RideDateTimeLocal: originalDate, - Miles: 12.2m, - RideMinutes: 41, - Temperature: null, - ExpectedVersion: 1, - WindSpeedMph: null, - WindDirectionDeg: null, - RelativeHumidityPercent: null, - CloudCoverPercent: null, - PrecipitationType: null, - WeatherUserOverridden: false - ) - ); - - Assert.True(result.IsSuccess); - Assert.Equal(0, weatherLookup.CallCount); - - var persistedRide = await context.Rides.SingleAsync(r => r.Id == ride.Id); - Assert.Equal(61m, persistedRide.Temperature); - Assert.Equal(8m, persistedRide.WindSpeedMph); - Assert.Equal(250, persistedRide.WindDirectionDeg); - Assert.Equal("rain", persistedRide.PrecipitationType); - } - - [Fact] - public async Task EditRideService_RefreshesSnapshotFields_FromCurrentSettings() - { - using var context = CreateDbContext(); - var user = new UserEntity - { - DisplayName = "Snapshot Edit Rider", - NormalizedName = "snapshot edit rider", - CreatedAtUtc = DateTime.UtcNow, - }; - context.Users.Add(user); - await context.SaveChangesAsync(); - - context.UserSettings.Add( - new UserSettingsEntity - { - UserId = user.UserId, - AverageCarMpg = 32m, - MileageRateCents = 65m, - YearlyGoalMiles = 1800m, - OilChangePrice = 70m, - UpdatedAtUtc = DateTime.UtcNow, - } - ); - await context.SaveChangesAsync(); - - var ride = new RideEntity - { - RiderId = user.UserId, - RideDateTimeLocal = DateTime.Now.AddDays(-1), - Miles = 8m, - RideMinutes = 28, - GasPricePerGallon = 3.49m, - SnapshotAverageCarMpg = 25m, - SnapshotMileageRateCents = 50m, - SnapshotYearlyGoalMiles = 1200m, - SnapshotOilChangePrice = 55m, - Version = 1, - CreatedAtUtc = DateTime.UtcNow, - }; - context.Rides.Add(ride); - await context.SaveChangesAsync(); - - var service = new EditRideService( - context, - new StubWeatherLookupService(), - NullLogger.Instance - ); - - var result = await service.ExecuteAsync( - user.UserId, - ride.Id, - new EditRideRequest( - RideDateTimeLocal: ride.RideDateTimeLocal, - Miles: 9m, - RideMinutes: 31, - Temperature: 60m, - GasPricePerGallon: 3.59m, - ExpectedVersion: 1 - ) - ); - - Assert.True(result.IsSuccess); - Assert.NotNull(result.EventPayload); - - var updatedRide = await context.Rides.SingleAsync(entity => entity.Id == ride.Id); - Assert.Equal(32m, updatedRide.SnapshotAverageCarMpg); - Assert.Equal(65m, updatedRide.SnapshotMileageRateCents); - Assert.Equal(1800m, updatedRide.SnapshotYearlyGoalMiles); - Assert.Equal(70m, updatedRide.SnapshotOilChangePrice); - - Assert.Equal(32m, result.EventPayload!.SnapshotAverageCarMpg); - Assert.Equal(65m, result.EventPayload.SnapshotMileageRateCents); - Assert.Equal(1800m, result.EventPayload.SnapshotYearlyGoalMiles); - Assert.Equal(70m, result.EventPayload.SnapshotOilChangePrice); - } - - private static BikeTrackingDbContext CreateDbContext() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - - return new BikeTrackingDbContext(options); - } -} - -internal sealed class StubWeatherLookupService : IWeatherLookupService -{ - public Task GetOrFetchAsync( - decimal latitude, - decimal longitude, - DateTime dateTimeUtc, - string? apiKey = null, - CancellationToken cancellationToken = default - ) => Task.FromResult(null); -} - -internal sealed class TrackingWeatherLookupService(WeatherData? response) : IWeatherLookupService -{ - public int CallCount { get; private set; } - - public Task GetOrFetchAsync( - decimal latitude, - decimal longitude, - DateTime dateTimeUtc, - string? apiKey = null, - CancellationToken cancellationToken = default - ) - { - CallCount++; - return Task.FromResult(response); - } -} diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.Edit.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.Edit.cs new file mode 100644 index 0000000..876cf1d --- /dev/null +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.Edit.cs @@ -0,0 +1,215 @@ +using System.Net; +using System.Net.Http.Json; +using BikeTracking.Api.Contracts; +using BikeTracking.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace BikeTracking.Api.Tests.Endpoints; + +public sealed partial class RidesEndpointsTests +{ + [Fact] + public async Task PutEditRide_WithValidRequest_Returns200AndUpdatedVersion() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Jules"); + var rideId = await host.RecordRideAsync( + userId, + miles: 8.5m, + rideMinutes: 30, + temperature: 65m + ); + + var request = new EditRideRequest( + RideDateTimeLocal: DateTime.Now.AddMinutes(-10), + Miles: 11.25m, + RideMinutes: 42, + Temperature: 68m, + ExpectedVersion: 1 + ); + + var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Equal(rideId, payload.RideId); + Assert.Equal(2, payload.NewVersion); + } + + [Fact] + public async Task PutEditRide_WithGasPrice_StoresGasPrice() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("GasPriceEdit"); + var rideId = await host.RecordRideAsync(userId, miles: 8.5m, gasPricePerGallon: 3.0000m); + + var request = new EditRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 10.25m, + RideMinutes: 39, + Temperature: 68m, + ExpectedVersion: 1, + GasPricePerGallon: 3.5555m + ); + + var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var scope = host.App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var ride = await dbContext.Rides.SingleAsync(r => r.Id == rideId); + Assert.Equal(3.5555m, ride.GasPricePerGallon); + } + + [Fact] + public async Task PutEditRide_WithNullGasPrice_StoresNull() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("GasPriceEditNull"); + var rideId = await host.RecordRideAsync(userId, miles: 8.5m, gasPricePerGallon: 3.0000m); + + var request = new EditRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 9.25m, + RideMinutes: 33, + Temperature: 68m, + ExpectedVersion: 1, + GasPricePerGallon: null + ); + + var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var scope = host.App.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var ride = await dbContext.Rides.SingleAsync(r => r.Id == rideId); + Assert.Null(ride.GasPricePerGallon); + } + + [Fact] + public async Task PutEditRide_WithInvalidPayload_Returns400() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Luca"); + var rideId = await host.RecordRideAsync(userId, miles: 8.5m); + + var request = new EditRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 0m, + RideMinutes: null, + Temperature: null, + ExpectedVersion: 1 + ); + + var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PutEditRide_WithMilesAboveMaximum_Returns400() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Liam"); + var rideId = await host.RecordRideAsync(userId, miles: 8.5m); + + var request = new EditRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 250m, + RideMinutes: null, + Temperature: null, + ExpectedVersion: 1 + ); + + var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PutEditRide_ForDifferentRiderRide_Returns403() + { + await using var host = await RecordRideApiHost.StartAsync(); + var ownerId = await host.SeedUserAsync("Mira"); + var otherUserId = await host.SeedUserAsync("Noah"); + var rideId = await host.RecordRideAsync(ownerId, miles: 8.5m); + + var request = new EditRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 10.2m, + RideMinutes: 39, + Temperature: 67m, + ExpectedVersion: 1 + ); + + var response = await host.Client.PutWithAuthAsync( + $"/api/rides/{rideId}", + request, + otherUserId + ); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task PutEditRide_WithStaleExpectedVersion_Returns409() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Omar"); + var rideId = await host.RecordRideAsync(userId, miles: 8.5m); + + var request = new EditRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 10.2m, + RideMinutes: 39, + Temperature: 67m, + ExpectedVersion: 99 + ); + + var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task PutEditRide_ThenGetHistory_ReturnsEditedMilesInRowsAndTotals() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Pia"); + var rideId = await host.RecordRideAsync( + userId, + miles: 6.0m, + rideMinutes: 31, + temperature: 64m + ); + + var editRequest = new EditRideRequest( + RideDateTimeLocal: DateTime.Now, + Miles: 10.25m, + RideMinutes: 35, + Temperature: 67m, + ExpectedVersion: 1 + ); + + var editResponse = await host.Client.PutWithAuthAsync( + $"/api/rides/{rideId}", + editRequest, + userId + ); + Assert.Equal(HttpStatusCode.OK, editResponse.StatusCode); + + var historyResponse = await host.Client.GetWithAuthAsync("/api/rides/history", userId); + Assert.Equal(HttpStatusCode.OK, historyResponse.StatusCode); + + var payload = await historyResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + + var editedRide = Assert.Single(payload.Rides, r => r.RideId == rideId); + Assert.Equal(10.25m, editedRide.Miles); + Assert.Equal(10.25m, payload.FilteredTotal.Miles); + Assert.Equal(10.25m, payload.Summaries.AllTime.Miles); + } +} diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.History.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.History.cs new file mode 100644 index 0000000..e4f61e1 --- /dev/null +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.History.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Net.Http.Json; +using BikeTracking.Api.Contracts; + +namespace BikeTracking.Api.Tests.Endpoints; + +public sealed partial class RidesEndpointsTests +{ + // History endpoint tests + + [Fact] + public async Task GetRideHistory_WithRides_ReturnsSuccessResponse() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Georgia"); + + // Record rides + await host.RecordRideAsync(userId, miles: 10.5m); + await host.RecordRideAsync(userId, miles: 5.2m); + + var response = await host.Client.GetWithAuthAsync("/api/rides/history", userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.NotNull(payload.Summaries); + Assert.NotNull(payload.FilteredTotal); + Assert.NotEmpty(payload.Rides); + Assert.True(payload.Rides.Count >= 2); + Assert.Equal(1, payload.Page); + Assert.True(payload.TotalRows >= 2); + } + + [Fact] + public async Task GetRideHistory_WithDateRangeFilter_ReturnsFilteredRows() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Harper"); + + await host.RecordRideAsync(userId, miles: 10m); + await host.RecordRideAsync(userId, miles: 5m); + + var today = DateOnly.FromDateTime(DateTime.Now); + var request = new HttpRequestMessage( + HttpMethod.Get, + $"/api/rides/history?from={today:yyyy-MM-dd}&to={today:yyyy-MM-dd}" + ); + request.Headers.Add("X-User-Id", userId.ToString()); + + var response = await host.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.True(payload.TotalRows >= 1); + Assert.True(payload.FilteredTotal.Miles > 0); + } + + [Fact] + public async Task GetRideHistory_WithoutRides_ReturnsEmptyWithZeroSummaries() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Henry"); + + var response = await host.Client.GetWithAuthAsync("/api/rides/history", userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + Assert.Empty(payload.Rides); + Assert.Equal(0, payload.TotalRows); + Assert.Equal(0, payload.Summaries.AllTime.Miles); + Assert.Equal(0, payload.FilteredTotal.Miles); + } + + [Fact] + public async Task GetRideHistory_WithoutAuth_Returns401() + { + await using var host = await RecordRideApiHost.StartAsync(); + + var response = await host.Client.GetAsync("/api/rides/history"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetRideHistory_WithInvalidDateRange_Returns400() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("Ivy"); + + var request = new HttpRequestMessage( + HttpMethod.Get, + "/api/rides/history?from=2025-12-31&to=2025-01-01" + ); + request.Headers.Add("X-User-Id", userId.ToString()); + var response = await host.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetRideHistory_ContainsGasPricePerGallon() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("GasPriceHistory"); + var rideId = await host.RecordRideAsync(userId, miles: 6.0m, gasPricePerGallon: 3.4444m); + + var response = await host.Client.GetWithAuthAsync("/api/rides/history", userId); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var payload = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(payload); + var ride = Assert.Single(payload.Rides, r => r.RideId == rideId); + Assert.Equal(3.4444m, ride.GasPricePerGallon); + } +} diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.RidePresets.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.RidePresets.cs new file mode 100644 index 0000000..365bb3f --- /dev/null +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.RidePresets.cs @@ -0,0 +1,150 @@ +using System.Net; +using System.Net.Http.Json; +using BikeTracking.Api.Contracts; + +namespace BikeTracking.Api.Tests.Endpoints; + +public sealed partial class RidesEndpointsTests +{ + [Fact] + public async Task RidePresetCrud_FullRoundTrip_IncludingExactTimeAndDelete() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("PresetCrud"); + + var createRequest = new UpsertRidePresetRequest( + Name: "Morning Commute", + PrimaryDirection: "SW", + PeriodTag: "morning", + ExactStartTimeLocal: "07:45", + DurationMinutes: 34, + Miles: 7.2m + ); + + var createResponse = await host.Client.PostWithAuthAsync( + "/api/rides/presets", + createRequest, + userId + ); + + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var created = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal("07:45", created.ExactStartTimeLocal); + Assert.Equal(7.2m, created.Miles); + + var listResponse = await host.Client.GetWithAuthAsync("/api/rides/presets", userId); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + + var listed = await listResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(listed); + var existing = Assert.Single(listed.Presets); + Assert.Equal(created.PresetId, existing.PresetId); + + var updateRequest = new UpsertRidePresetRequest( + Name: "Morning Commute", + PrimaryDirection: "NE", + PeriodTag: "morning", + ExactStartTimeLocal: "08:05", + DurationMinutes: 40, + Miles: 8.1m + ); + + var updateResponse = await host.Client.PutWithAuthAsync( + $"/api/rides/presets/{created.PresetId}", + updateRequest, + userId + ); + + Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode); + var updated = await updateResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(updated); + Assert.Equal("08:05", updated.ExactStartTimeLocal); + Assert.Equal("NE", updated.PrimaryDirection); + Assert.Equal(8.1m, updated.Miles); + + var deleteRequest = new HttpRequestMessage( + HttpMethod.Delete, + $"/api/rides/presets/{created.PresetId}" + ); + deleteRequest.Headers.Add("X-User-Id", userId.ToString()); + var deleteResponse = await host.Client.SendAsync(deleteRequest); + + Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); + } + + [Fact] + public async Task CreateRidePreset_DuplicateNameForSameRider_Returns400() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("PresetDuplicate"); + + var request = new UpsertRidePresetRequest( + Name: "Afternoon Return", + PrimaryDirection: "NE", + PeriodTag: "afternoon", + ExactStartTimeLocal: "17:35", + DurationMinutes: 32, + Miles: 6.8m + ); + + var first = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + + var second = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + } + + [Fact] + public async Task CreateRidePreset_MorningWithOverrideDirection_PersistsOverrideNotDefault() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("PresetDirectionOverride"); + + // Morning default would be SW, but rider overrides to North + var request = new UpsertRidePresetRequest( + Name: "Custom Morning", + PrimaryDirection: "North", + PeriodTag: "morning", + ExactStartTimeLocal: "07:30", + DurationMinutes: 35, + Miles: 9.3m + ); + + var response = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var created = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal("North", created.PrimaryDirection); + Assert.Equal("morning", created.PeriodTag); + Assert.Equal(9.3m, created.Miles); + } + + [Fact] + public async Task CreateRidePreset_AfternoonWithOverrideDirection_PersistsOverrideNotDefault() + { + await using var host = await RecordRideApiHost.StartAsync(); + var userId = await host.SeedUserAsync("PresetDirectionOverrideAft"); + + // Afternoon default would be NE, but rider overrides to South + var request = new UpsertRidePresetRequest( + Name: "Custom Afternoon", + PrimaryDirection: "South", + PeriodTag: "afternoon", + ExactStartTimeLocal: "17:15", + DurationMinutes: 30, + Miles: 8.7m + ); + + var response = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var created = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal("South", created.PrimaryDirection); + Assert.Equal("afternoon", created.PeriodTag); + Assert.Equal(8.7m, created.Miles); + } +} diff --git a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs index 5670c79..4290ca4 100644 --- a/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs +++ b/src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs @@ -11,7 +11,7 @@ namespace BikeTracking.Api.Tests.Endpoints; -public sealed class RidesEndpointsTests +public sealed partial class RidesEndpointsTests { [Fact] public async Task PostRecordRide_WithValidRequest_Returns201AndRideId() @@ -295,462 +295,6 @@ public async Task PostRecordRide_WithNullGasPrice_PersistsNull() Assert.Null(ride.GasPricePerGallon); } - [Fact] - public async Task RidePresetCrud_FullRoundTrip_IncludingExactTimeAndDelete() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("PresetCrud"); - - var createRequest = new UpsertRidePresetRequest( - Name: "Morning Commute", - PrimaryDirection: "SW", - PeriodTag: "morning", - ExactStartTimeLocal: "07:45", - DurationMinutes: 34, - Miles: 7.2m - ); - - var createResponse = await host.Client.PostWithAuthAsync( - "/api/rides/presets", - createRequest, - userId - ); - - Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); - - var created = await createResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(created); - Assert.Equal("07:45", created.ExactStartTimeLocal); - Assert.Equal(7.2m, created.Miles); - - var listResponse = await host.Client.GetWithAuthAsync("/api/rides/presets", userId); - Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); - - var listed = await listResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(listed); - var existing = Assert.Single(listed.Presets); - Assert.Equal(created.PresetId, existing.PresetId); - - var updateRequest = new UpsertRidePresetRequest( - Name: "Morning Commute", - PrimaryDirection: "NE", - PeriodTag: "morning", - ExactStartTimeLocal: "08:05", - DurationMinutes: 40, - Miles: 8.1m - ); - - var updateResponse = await host.Client.PutWithAuthAsync( - $"/api/rides/presets/{created.PresetId}", - updateRequest, - userId - ); - - Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode); - var updated = await updateResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(updated); - Assert.Equal("08:05", updated.ExactStartTimeLocal); - Assert.Equal("NE", updated.PrimaryDirection); - Assert.Equal(8.1m, updated.Miles); - - var deleteRequest = new HttpRequestMessage( - HttpMethod.Delete, - $"/api/rides/presets/{created.PresetId}" - ); - deleteRequest.Headers.Add("X-User-Id", userId.ToString()); - var deleteResponse = await host.Client.SendAsync(deleteRequest); - - Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); - } - - [Fact] - public async Task CreateRidePreset_DuplicateNameForSameRider_Returns400() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("PresetDuplicate"); - - var request = new UpsertRidePresetRequest( - Name: "Afternoon Return", - PrimaryDirection: "NE", - PeriodTag: "afternoon", - ExactStartTimeLocal: "17:35", - DurationMinutes: 32, - Miles: 6.8m - ); - - var first = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); - Assert.Equal(HttpStatusCode.Created, first.StatusCode); - - var second = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); - Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); - } - - [Fact] - public async Task CreateRidePreset_MorningWithOverrideDirection_PersistsOverrideNotDefault() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("PresetDirectionOverride"); - - // Morning default would be SW, but rider overrides to North - var request = new UpsertRidePresetRequest( - Name: "Custom Morning", - PrimaryDirection: "North", - PeriodTag: "morning", - ExactStartTimeLocal: "07:30", - DurationMinutes: 35, - Miles: 9.3m - ); - - var response = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - - var created = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(created); - Assert.Equal("North", created.PrimaryDirection); - Assert.Equal("morning", created.PeriodTag); - Assert.Equal(9.3m, created.Miles); - } - - [Fact] - public async Task CreateRidePreset_AfternoonWithOverrideDirection_PersistsOverrideNotDefault() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("PresetDirectionOverrideAft"); - - // Afternoon default would be NE, but rider overrides to South - var request = new UpsertRidePresetRequest( - Name: "Custom Afternoon", - PrimaryDirection: "South", - PeriodTag: "afternoon", - ExactStartTimeLocal: "17:15", - DurationMinutes: 30, - Miles: 8.7m - ); - - var response = await host.Client.PostWithAuthAsync("/api/rides/presets", request, userId); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - - var created = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(created); - Assert.Equal("South", created.PrimaryDirection); - Assert.Equal("afternoon", created.PeriodTag); - Assert.Equal(8.7m, created.Miles); - } - - // History endpoint tests - - [Fact] - public async Task GetRideHistory_WithRides_ReturnsSuccessResponse() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("Georgia"); - - // Record rides - await host.RecordRideAsync(userId, miles: 10.5m); - await host.RecordRideAsync(userId, miles: 5.2m); - - var response = await host.Client.GetWithAuthAsync("/api/rides/history", userId); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - Assert.NotNull(payload.Summaries); - Assert.NotNull(payload.FilteredTotal); - Assert.NotEmpty(payload.Rides); - Assert.True(payload.Rides.Count >= 2); - Assert.Equal(1, payload.Page); - Assert.True(payload.TotalRows >= 2); - } - - [Fact] - public async Task GetRideHistory_WithDateRangeFilter_ReturnsFilteredRows() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("Harper"); - - await host.RecordRideAsync(userId, miles: 10m); - await host.RecordRideAsync(userId, miles: 5m); - - var today = DateOnly.FromDateTime(DateTime.Now); - var request = new HttpRequestMessage( - HttpMethod.Get, - $"/api/rides/history?from={today:yyyy-MM-dd}&to={today:yyyy-MM-dd}" - ); - request.Headers.Add("X-User-Id", userId.ToString()); - - var response = await host.Client.SendAsync(request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - Assert.True(payload.TotalRows >= 1); - Assert.True(payload.FilteredTotal.Miles > 0); - } - - [Fact] - public async Task GetRideHistory_WithoutRides_ReturnsEmptyWithZeroSummaries() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("Henry"); - - var response = await host.Client.GetWithAuthAsync("/api/rides/history", userId); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - Assert.Empty(payload.Rides); - Assert.Equal(0, payload.TotalRows); - Assert.Equal(0, payload.Summaries.AllTime.Miles); - Assert.Equal(0, payload.FilteredTotal.Miles); - } - - [Fact] - public async Task GetRideHistory_WithoutAuth_Returns401() - { - await using var host = await RecordRideApiHost.StartAsync(); - - var response = await host.Client.GetAsync("/api/rides/history"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } - - [Fact] - public async Task GetRideHistory_WithInvalidDateRange_Returns400() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("Ivy"); - - var request = new HttpRequestMessage( - HttpMethod.Get, - "/api/rides/history?from=2025-12-31&to=2025-01-01" - ); - request.Headers.Add("X-User-Id", userId.ToString()); - var response = await host.Client.SendAsync(request); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task PutEditRide_WithValidRequest_Returns200AndUpdatedVersion() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("Jules"); - var rideId = await host.RecordRideAsync( - userId, - miles: 8.5m, - rideMinutes: 30, - temperature: 65m - ); - - var request = new EditRideRequest( - RideDateTimeLocal: DateTime.Now.AddMinutes(-10), - Miles: 11.25m, - RideMinutes: 42, - Temperature: 68m, - ExpectedVersion: 1 - ); - - var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - Assert.Equal(rideId, payload.RideId); - Assert.Equal(2, payload.NewVersion); - } - - [Fact] - public async Task PutEditRide_WithGasPrice_StoresGasPrice() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("GasPriceEdit"); - var rideId = await host.RecordRideAsync(userId, miles: 8.5m, gasPricePerGallon: 3.0000m); - - var request = new EditRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 10.25m, - RideMinutes: 39, - Temperature: 68m, - ExpectedVersion: 1, - GasPricePerGallon: 3.5555m - ); - - var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - using var scope = host.App.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var ride = await dbContext.Rides.SingleAsync(r => r.Id == rideId); - Assert.Equal(3.5555m, ride.GasPricePerGallon); - } - - [Fact] - public async Task PutEditRide_WithNullGasPrice_StoresNull() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("GasPriceEditNull"); - var rideId = await host.RecordRideAsync(userId, miles: 8.5m, gasPricePerGallon: 3.0000m); - - var request = new EditRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 9.25m, - RideMinutes: 33, - Temperature: 68m, - ExpectedVersion: 1, - GasPricePerGallon: null - ); - - var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - using var scope = host.App.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var ride = await dbContext.Rides.SingleAsync(r => r.Id == rideId); - Assert.Null(ride.GasPricePerGallon); - } - - [Fact] - public async Task PutEditRide_WithInvalidPayload_Returns400() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("Luca"); - var rideId = await host.RecordRideAsync(userId, miles: 8.5m); - - var request = new EditRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 0m, - RideMinutes: null, - Temperature: null, - ExpectedVersion: 1 - ); - - var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task PutEditRide_WithMilesAboveMaximum_Returns400() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("Liam"); - var rideId = await host.RecordRideAsync(userId, miles: 8.5m); - - var request = new EditRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 250m, - RideMinutes: null, - Temperature: null, - ExpectedVersion: 1 - ); - - var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task PutEditRide_ForDifferentRiderRide_Returns403() - { - await using var host = await RecordRideApiHost.StartAsync(); - var ownerId = await host.SeedUserAsync("Mira"); - var otherUserId = await host.SeedUserAsync("Noah"); - var rideId = await host.RecordRideAsync(ownerId, miles: 8.5m); - - var request = new EditRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 10.2m, - RideMinutes: 39, - Temperature: 67m, - ExpectedVersion: 1 - ); - - var response = await host.Client.PutWithAuthAsync( - $"/api/rides/{rideId}", - request, - otherUserId - ); - - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } - - [Fact] - public async Task PutEditRide_WithStaleExpectedVersion_Returns409() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("Omar"); - var rideId = await host.RecordRideAsync(userId, miles: 8.5m); - - var request = new EditRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 10.2m, - RideMinutes: 39, - Temperature: 67m, - ExpectedVersion: 99 - ); - - var response = await host.Client.PutWithAuthAsync($"/api/rides/{rideId}", request, userId); - - Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); - } - - [Fact] - public async Task PutEditRide_ThenGetHistory_ReturnsEditedMilesInRowsAndTotals() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("Pia"); - var rideId = await host.RecordRideAsync( - userId, - miles: 6.0m, - rideMinutes: 31, - temperature: 64m - ); - - var editRequest = new EditRideRequest( - RideDateTimeLocal: DateTime.Now, - Miles: 10.25m, - RideMinutes: 35, - Temperature: 67m, - ExpectedVersion: 1 - ); - - var editResponse = await host.Client.PutWithAuthAsync( - $"/api/rides/{rideId}", - editRequest, - userId - ); - Assert.Equal(HttpStatusCode.OK, editResponse.StatusCode); - - var historyResponse = await host.Client.GetWithAuthAsync("/api/rides/history", userId); - Assert.Equal(HttpStatusCode.OK, historyResponse.StatusCode); - - var payload = await historyResponse.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - - var editedRide = Assert.Single(payload.Rides, r => r.RideId == rideId); - Assert.Equal(10.25m, editedRide.Miles); - Assert.Equal(10.25m, payload.FilteredTotal.Miles); - Assert.Equal(10.25m, payload.Summaries.AllTime.Miles); - } - - [Fact] - public async Task GetRideHistory_ContainsGasPricePerGallon() - { - await using var host = await RecordRideApiHost.StartAsync(); - var userId = await host.SeedUserAsync("GasPriceHistory"); - var rideId = await host.RecordRideAsync(userId, miles: 6.0m, gasPricePerGallon: 3.4444m); - - var response = await host.Client.GetWithAuthAsync("/api/rides/history", userId); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(payload); - var ride = Assert.Single(payload.Rides, r => r.RideId == rideId); - Assert.Equal(3.4444m, ride.GasPricePerGallon); - } - private sealed class RecordRideApiHost : IAsyncDisposable { private readonly WebApplication app; diff --git a/src/BikeTracking.Api.Tests/TestSupport/TestFactories.cs b/src/BikeTracking.Api.Tests/TestSupport/TestFactories.cs index 559d27f..109a1fc 100644 --- a/src/BikeTracking.Api.Tests/TestSupport/TestFactories.cs +++ b/src/BikeTracking.Api.Tests/TestSupport/TestFactories.cs @@ -42,3 +42,19 @@ public bool Verify(string pin, byte[] salt, byte[] expectedHash, int iterations) return verifyPin(pin); } } + +/// +/// Deterministic for tests. Returns a fixed instant so +/// calendar-boundary logic (e.g. "this week"/"this month") never depends on wall-clock time. +/// Local time zone is fixed to UTC so matches . +/// +internal sealed class FakeTimeProvider(DateTimeOffset now) : TimeProvider +{ + private DateTimeOffset now = now; + + public override DateTimeOffset GetUtcNow() => now; + + public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; + + public void SetNow(DateTimeOffset value) => now = value; +} diff --git a/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs index ae12d15..d02011c 100644 --- a/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs +++ b/src/BikeTracking.Api/Application/Dashboard/GetAdvancedDashboardService.cs @@ -12,7 +12,10 @@ namespace BikeTracking.Api.Application.Dashboard; /// by weekly, monthly, yearly, and all-time calendar windows, personalised suggestions, /// and reminder flags when required user settings are missing. /// -public sealed class GetAdvancedDashboardService(BikeTrackingDbContext dbContext) +public sealed class GetAdvancedDashboardService( + BikeTrackingDbContext dbContext, + TimeProvider timeProvider +) { /// /// Loads all rides, user settings, and gas-price lookups for , @@ -46,7 +49,7 @@ public async Task GetAsync( .AsNoTracking() .ToListAsync(cancellationToken); - var nowLocal = DateTime.Now; + var nowLocal = timeProvider.GetLocalNow().DateTime; // Calendar-based windows (not rolling) for consistency with the main dashboard. // Using calendar periods means "this week" always starts on Monday, "this month" diff --git a/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs index ad7125b..eecaab5 100644 --- a/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs +++ b/src/BikeTracking.Api/Application/Dashboard/GetDashboardService.cs @@ -6,7 +6,7 @@ namespace BikeTracking.Api.Application.Dashboard; -public sealed class GetDashboardService(BikeTrackingDbContext dbContext) +public sealed class GetDashboardService(BikeTrackingDbContext dbContext, TimeProvider timeProvider) { public async Task GetAsync( long riderId, @@ -23,7 +23,7 @@ public async Task GetAsync( .UserSettings.AsNoTracking() .SingleOrDefaultAsync(setting => setting.UserId == riderId, cancellationToken); - var nowLocal = DateTime.Now; + var nowLocal = timeProvider.GetLocalNow().DateTime; var currentMonthStart = new DateTime(nowLocal.Year, nowLocal.Month, 1); var nextMonthStart = currentMonthStart.AddMonths(1); var currentYearStart = new DateTime(nowLocal.Year, 1, 1); diff --git a/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs b/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs index 6503da7..f2436c4 100644 --- a/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs +++ b/src/BikeTracking.Api/Application/Dashboard/GetYearStatsDashboardService.cs @@ -17,7 +17,10 @@ namespace BikeTracking.Api.Application.Dashboard; /// unchanged, so historical accuracy (FR-006) and existing rolling/all-time behavior /// (FR-005) are preserved. /// -public sealed class GetYearStatsDashboardService(BikeTrackingDbContext dbContext) +public sealed class GetYearStatsDashboardService( + BikeTrackingDbContext dbContext, + TimeProvider timeProvider +) { /// /// Loads the rider's rides for and returns the year-scoped @@ -56,7 +59,8 @@ await dbContext && e.ExpenseDate >= yearStart && e.ExpenseDate < nextYearStart ) - .SumAsync(e => (decimal?)e.Amount, cancellationToken) ?? 0m; + .SumAsync(e => (decimal?)e.Amount, cancellationToken) + ?? 0m; return new YearStatsDashboardResponse( Year: year, @@ -135,7 +139,7 @@ public async Task GetAvailableYearsAsync( var descendingYears = years.OrderByDescending(y => y).ToList(); return new AvailableYearsResponse( - descendingYears.Count > 0 ? descendingYears : [DateTime.Now.Year] + descendingYears.Count > 0 ? descendingYears : [timeProvider.GetLocalNow().Year] ); } diff --git a/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs b/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs index 0b869ed..0c7d893 100644 --- a/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs +++ b/src/BikeTracking.Api/Application/Rides/GetRideHistoryService.cs @@ -7,7 +7,10 @@ namespace BikeTracking.Api.Application.Rides; /// /// Provides query operations for retrieving ride history with filtering, summaries, and pagination. /// -public sealed class GetRideHistoryService(BikeTrackingDbContext dbContext) +public sealed class GetRideHistoryService( + BikeTrackingDbContext dbContext, + TimeProvider timeProvider +) { /// /// Retrieves paginated ride history with summary totals for a specific rider. @@ -50,7 +53,7 @@ public async Task GetRideHistoryAsync( .ToListAsync(cancellationToken); // Calculate period boundaries in local dates (assuming rides are stored in rider's local time) - var today = DateOnly.FromDateTime(DateTime.Now); + var today = DateOnly.FromDateTime(timeProvider.GetLocalNow().DateTime); var firstOfMonth = new DateOnly(today.Year, today.Month, 1); var firstOfYear = new DateOnly(today.Year, 1, 1); diff --git a/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs index a137a96..6fb3d88 100644 --- a/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs +++ b/src/BikeTracking.Api/Application/Rides/WeatherLookupService.cs @@ -38,7 +38,8 @@ public sealed class OpenMeteoWeatherLookupService( BikeTrackingDbContext dbContext, IHttpClientFactory httpClientFactory, IConfiguration configuration, - ILogger logger + ILogger logger, + TimeProvider timeProvider ) : IWeatherLookupService { private const string DataSourceName = "OpenMeteo"; @@ -101,7 +102,7 @@ ILogger logger ); // Determine which API to call (forecast vs. archive) - var daysDiff = (int)(DateTime.UtcNow.Date - dateTimeUtc.Date).TotalDays; + var daysDiff = (int)(timeProvider.GetUtcNow().Date - dateTimeUtc.Date).TotalDays; var isHistorical = daysDiff > 92; var clientName = isHistorical ? "OpenMeteoArchive" : "OpenMeteoForecast"; var requestPath = isHistorical ? "/v1/archive" : "/v1/forecast"; @@ -192,7 +193,7 @@ out var precipType CloudCoverPercent = cloudCover, PrecipitationType = precipType, DataSource = DataSourceName, - RetrievedAtUtc = DateTime.UtcNow, + RetrievedAtUtc = timeProvider.GetUtcNow().UtcDateTime, Status = "success", }; @@ -281,7 +282,7 @@ CancellationToken cancellationToken LatitudeRounded = latRounded, LongitudeRounded = lonRounded, DataSource = DataSourceName, - RetrievedAtUtc = DateTime.UtcNow, + RetrievedAtUtc = timeProvider.GetUtcNow().UtcDateTime, Status = "error", }; diff --git a/src/BikeTracking.Api/Endpoints/DashboardEndpoints.cs b/src/BikeTracking.Api/Endpoints/DashboardEndpoints.cs index aff7b06..e41c458 100644 --- a/src/BikeTracking.Api/Endpoints/DashboardEndpoints.cs +++ b/src/BikeTracking.Api/Endpoints/DashboardEndpoints.cs @@ -80,6 +80,7 @@ private static async Task GetYearStatsDashboardAsync( HttpContext context, [FromQuery] string? year, [FromServices] GetYearStatsDashboardService yearStatsDashboardService, + [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken ) { @@ -90,7 +91,7 @@ CancellationToken cancellationToken } var minYear = 1900; - var maxYear = DateTime.Now.Year + 1; + var maxYear = timeProvider.GetLocalNow().Year + 1; if (!int.TryParse(year, out var parsedYear) || parsedYear < minYear || parsedYear > maxYear) { diff --git a/src/BikeTracking.Api/Program.cs b/src/BikeTracking.Api/Program.cs index 42753c5..41e8ae5 100644 --- a/src/BikeTracking.Api/Program.cs +++ b/src/BikeTracking.Api/Program.cs @@ -40,6 +40,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped();