Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/agent/testing-and-quality-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading