Skip to content

Commit 356bd20

Browse files
author
aligneddev
committed
feat: Enhance gas price tracking with EIA source integration and update tests
1 parent e601e63 commit 356bd20

12 files changed

Lines changed: 298 additions & 34 deletions

File tree

specs/010-gas-price-lookup/research.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
- Embedding a static price table — rejected: goes stale and requires manual maintenance.
1919

2020
**EIA API Details**:
21+
Get a key at https://www.eia.gov/opendata/
22+
2123

2224
| Field | Value |
2325
|---|---|
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using BikeTracking.Api.Application.Rides;
4+
using BikeTracking.Api.Contracts;
5+
using BikeTracking.Api.Endpoints;
6+
using BikeTracking.Api.Infrastructure.Persistence;
7+
using BikeTracking.Api.Infrastructure.Persistence.Entities;
8+
using Microsoft.AspNetCore.TestHost;
9+
using Microsoft.EntityFrameworkCore;
10+
11+
namespace BikeTracking.Api.Tests.Endpoints;
12+
13+
public sealed class RidesEndpointsSqliteIntegrationTests
14+
{
15+
private static readonly string[] SqliteUnsupportedConstraintMigrations =
16+
[
17+
"20260327165005_AddRideMilesUpperBound",
18+
"20260327171355_FixRideMilesUpperBoundNumericComparison",
19+
];
20+
21+
[Fact]
22+
public async Task GetRideHistory_WithSqliteMigrationsApplied_ReturnsGasPricePerGallon()
23+
{
24+
await using var host = await SqliteRidesApiHost.StartAsync();
25+
26+
var appliedMigrations = await host.GetAppliedMigrationsAsync();
27+
Assert.Contains(
28+
appliedMigrations,
29+
migration =>
30+
migration.Contains("AddGasPriceToRidesAndLookupCache", StringComparison.Ordinal)
31+
);
32+
33+
var userId = await host.SeedUserAsync("SqliteHistory");
34+
var rideId = await host.RecordRideAsync(userId, miles: 6.25m, gasPricePerGallon: 3.4567m);
35+
36+
var response = await host.Client.GetWithAuthAsync("/api/rides/history", userId);
37+
38+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
39+
40+
var payload = await response.Content.ReadFromJsonAsync<RideHistoryResponse>();
41+
Assert.NotNull(payload);
42+
43+
var ride = Assert.Single(payload.Rides, r => r.RideId == rideId);
44+
Assert.Equal(3.4567m, ride.GasPricePerGallon);
45+
}
46+
47+
private sealed class SqliteRidesApiHost(WebApplication app, string databasePath)
48+
: IAsyncDisposable
49+
{
50+
public WebApplication App { get; } = app;
51+
public HttpClient Client { get; } = app.GetTestClient();
52+
private string DatabasePath { get; } = databasePath;
53+
54+
public static async Task<SqliteRidesApiHost> StartAsync()
55+
{
56+
var builder = WebApplication.CreateBuilder();
57+
builder.WebHost.UseTestServer();
58+
59+
var databasePath = Path.Combine(
60+
Path.GetTempPath(),
61+
$"biketracking-api-tests-{Guid.NewGuid():N}.db"
62+
);
63+
64+
builder.Services.AddDbContext<BikeTrackingDbContext>(options =>
65+
options.UseSqlite($"Data Source={databasePath}")
66+
);
67+
68+
builder
69+
.Services.AddAuthentication("test")
70+
.AddScheme<TestAuthenticationSchemeOptions, TestAuthenticationHandler>(
71+
"test",
72+
_ => { }
73+
);
74+
builder.Services.AddAuthorization();
75+
76+
builder.Services.AddScoped<RecordRideService>();
77+
builder.Services.AddScoped<GetRideDefaultsService>();
78+
builder.Services.AddScoped<GetQuickRideOptionsService>();
79+
builder.Services.AddScoped<GetRideHistoryService>();
80+
builder.Services.AddScoped<EditRideService>();
81+
builder.Services.AddScoped<DeleteRideService>();
82+
builder.Services.AddScoped<IGasPriceLookupService, StubGasPriceLookupService>();
83+
84+
var app = builder.Build();
85+
86+
await using (var scope = app.Services.CreateAsyncScope())
87+
{
88+
var dbContext = scope.ServiceProvider.GetRequiredService<BikeTrackingDbContext>();
89+
90+
// SQLite cannot execute DropCheckConstraint migrations. Apply the
91+
// supported migrations, mark those legacy migrations as applied,
92+
// then continue migration so endpoint queries run on migrated schema.
93+
await dbContext.Database.MigrateAsync("20260327000000_AddRideVersion");
94+
95+
var applied = (await dbContext.Database.GetAppliedMigrationsAsync()).ToHashSet();
96+
foreach (var migration in SqliteUnsupportedConstraintMigrations)
97+
{
98+
if (applied.Contains(migration))
99+
{
100+
continue;
101+
}
102+
103+
await dbContext.Database.ExecuteSqlRawAsync(
104+
"INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES ({0}, {1})",
105+
migration,
106+
"10.0.5"
107+
);
108+
}
109+
110+
await dbContext.Database.MigrateAsync();
111+
}
112+
113+
app.UseAuthentication();
114+
app.UseAuthorization();
115+
app.MapRidesEndpoints();
116+
await app.StartAsync();
117+
118+
return new SqliteRidesApiHost(app, databasePath);
119+
}
120+
121+
public async Task<IReadOnlyList<string>> GetAppliedMigrationsAsync()
122+
{
123+
await using var scope = App.Services.CreateAsyncScope();
124+
var dbContext = scope.ServiceProvider.GetRequiredService<BikeTrackingDbContext>();
125+
var migrations = await dbContext.Database.GetAppliedMigrationsAsync();
126+
return migrations.ToArray();
127+
}
128+
129+
public async Task<long> SeedUserAsync(string displayName)
130+
{
131+
await using var scope = App.Services.CreateAsyncScope();
132+
var dbContext = scope.ServiceProvider.GetRequiredService<BikeTrackingDbContext>();
133+
134+
var user = new UserEntity
135+
{
136+
DisplayName = displayName,
137+
NormalizedName = displayName.ToLowerInvariant(),
138+
CreatedAtUtc = DateTime.UtcNow,
139+
IsActive = true,
140+
};
141+
142+
dbContext.Users.Add(user);
143+
await dbContext.SaveChangesAsync();
144+
return user.UserId;
145+
}
146+
147+
public async Task<int> RecordRideAsync(
148+
long userId,
149+
decimal miles,
150+
int? rideMinutes = null,
151+
decimal? temperature = null,
152+
decimal? gasPricePerGallon = null
153+
)
154+
{
155+
await using var scope = App.Services.CreateAsyncScope();
156+
var dbContext = scope.ServiceProvider.GetRequiredService<BikeTrackingDbContext>();
157+
158+
var ride = new RideEntity
159+
{
160+
RiderId = userId,
161+
RideDateTimeLocal = DateTime.Now,
162+
Miles = miles,
163+
RideMinutes = rideMinutes,
164+
Temperature = temperature,
165+
GasPricePerGallon = gasPricePerGallon,
166+
CreatedAtUtc = DateTime.UtcNow,
167+
};
168+
169+
dbContext.Rides.Add(ride);
170+
await dbContext.SaveChangesAsync();
171+
return ride.Id;
172+
}
173+
174+
public async ValueTask DisposeAsync()
175+
{
176+
Client.Dispose();
177+
await App.StopAsync();
178+
await App.DisposeAsync();
179+
180+
if (File.Exists(DatabasePath))
181+
{
182+
try
183+
{
184+
File.Delete(DatabasePath);
185+
}
186+
catch (IOException)
187+
{
188+
// Ignore cleanup failure from transient file locks in test teardown.
189+
}
190+
}
191+
}
192+
}
193+
}

src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsTests.cs

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ public async Task GetGasPrice_WithValidDate_ReturnsShape()
166166
var payload = await response.Content.ReadFromJsonAsync<GasPriceResponse>();
167167
Assert.NotNull(payload);
168168
Assert.Equal("2026-03-31", payload.Date);
169+
Assert.Equal("Source: U.S. Energy Information Administration (EIA)", payload.DataSource);
169170
}
170171

171172
[Fact]
@@ -625,19 +626,10 @@ public async Task GetRideHistory_ContainsGasPricePerGallon()
625626
Assert.Equal(3.4444m, ride.GasPricePerGallon);
626627
}
627628

628-
private sealed class RecordRideApiHost : IAsyncDisposable
629+
private sealed class RecordRideApiHost(WebApplication app) : IAsyncDisposable
629630
{
630-
private readonly WebApplication _app;
631-
632-
public RecordRideApiHost(WebApplication app)
633-
{
634-
_app = app;
635-
App = app;
636-
Client = app.GetTestClient();
637-
}
638-
639-
public WebApplication App { get; }
640-
public HttpClient Client { get; }
631+
public WebApplication App { get; } = app;
632+
public HttpClient Client { get; } = app.GetTestClient();
641633

642634
public static async Task<RecordRideApiHost> StartAsync()
643635
{
@@ -675,7 +667,7 @@ public static async Task<RecordRideApiHost> StartAsync()
675667

676668
public async Task<long> SeedUserAsync(string displayName)
677669
{
678-
using var scope = _app.Services.CreateScope();
670+
using var scope = app.Services.CreateScope();
679671
var dbContext = scope.ServiceProvider.GetRequiredService<BikeTrackingDbContext>();
680672

681673
var user = new UserEntity
@@ -699,7 +691,7 @@ public async Task<int> RecordRideAsync(
699691
decimal? gasPricePerGallon = null
700692
)
701693
{
702-
using var scope = _app.Services.CreateScope();
694+
using var scope = app.Services.CreateScope();
703695
var dbContext = scope.ServiceProvider.GetRequiredService<BikeTrackingDbContext>();
704696

705697
var ride = new RideEntity
@@ -721,8 +713,8 @@ public async Task<int> RecordRideAsync(
721713
public async ValueTask DisposeAsync()
722714
{
723715
Client.Dispose();
724-
await _app.StopAsync();
725-
await _app.DisposeAsync();
716+
await app.StopAsync();
717+
await app.DisposeAsync();
726718
}
727719
}
728720
}

src/BikeTracking.Api/Endpoints/RidesEndpoints.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ namespace BikeTracking.Api.Endpoints;
66

77
public static class RidesEndpoints
88
{
9+
private const string EiaGasPriceSource = "Source: U.S. Energy Information Administration (EIA)";
10+
911
public static IEndpointRouteBuilder MapRidesEndpoints(this IEndpointRouteBuilder endpoints)
1012
{
1113
var group = endpoints.MapGroup("/api/rides");
@@ -172,7 +174,7 @@ CancellationToken cancellationToken
172174
Date: parsedDate.ToString("yyyy-MM-dd"),
173175
PricePerGallon: price,
174176
IsAvailable: price.HasValue,
175-
DataSource: price.HasValue ? "EIA_EPM0_NUS_Weekly" : null
177+
DataSource: price.HasValue ? EiaGasPriceSource : null
176178
)
177179
);
178180
}

src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260323134325_AddRidesTable.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
using System;
2+
using BikeTracking.Api.Infrastructure.Persistence;
3+
using Microsoft.EntityFrameworkCore.Infrastructure;
24
using Microsoft.EntityFrameworkCore.Migrations;
35

46
#nullable disable
57

68
namespace BikeTracking.Api.Infrastructure.Persistence.Migrations
79
{
810
/// <inheritdoc />
11+
[DbContext(typeof(BikeTrackingDbContext))]
12+
[Migration("20260323134325_AddRidesTable")]
913
public partial class AddRidesTable : Migration
1014
{
1115
/// <inheritdoc />

src/BikeTracking.Api/Infrastructure/Persistence/Migrations/20260327165005_AddRideMilesUpperBound.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
using Microsoft.EntityFrameworkCore.Migrations;
1+
using BikeTracking.Api.Infrastructure.Persistence;
2+
using Microsoft.EntityFrameworkCore.Infrastructure;
3+
using Microsoft.EntityFrameworkCore.Migrations;
24

35
#nullable disable
46

57
namespace BikeTracking.Api.Infrastructure.Persistence.Migrations
68
{
79
/// <inheritdoc />
10+
[DbContext(typeof(BikeTrackingDbContext))]
11+
[Migration("20260327165005_AddRideMilesUpperBound")]
812
public partial class AddRideMilesUpperBound : Migration
913
{
1014
/// <inheritdoc />
Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
using System;
2+
using BikeTracking.Api.Infrastructure.Persistence;
3+
using Microsoft.EntityFrameworkCore.Infrastructure;
24
using Microsoft.EntityFrameworkCore.Migrations;
35

46
#nullable disable
57

68
namespace BikeTracking.Api.Infrastructure.Persistence.Migrations
79
{
810
/// <inheritdoc />
11+
[DbContext(typeof(BikeTrackingDbContext))]
12+
[Migration("20260330202303_AddUserSettingsTable")]
913
public partial class AddUserSettingsTable : Migration
1014
{
1115
/// <inheritdoc />
@@ -20,34 +24,57 @@ protected override void Up(MigrationBuilder migrationBuilder)
2024
YearlyGoalMiles = table.Column<decimal>(type: "TEXT", nullable: true),
2125
OilChangePrice = table.Column<decimal>(type: "TEXT", nullable: true),
2226
MileageRateCents = table.Column<decimal>(type: "TEXT", nullable: true),
23-
LocationLabel = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
27+
LocationLabel = table.Column<string>(
28+
type: "TEXT",
29+
maxLength: 200,
30+
nullable: true
31+
),
2432
Latitude = table.Column<decimal>(type: "TEXT", nullable: true),
2533
Longitude = table.Column<decimal>(type: "TEXT", nullable: true),
26-
UpdatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
34+
UpdatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
2735
},
2836
constraints: table =>
2937
{
3038
table.PrimaryKey("PK_UserSettings", x => x.UserId);
31-
table.CheckConstraint("CK_UserSettings_AverageCarMpg_Positive", "\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0");
32-
table.CheckConstraint("CK_UserSettings_Latitude_Range", "\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)");
33-
table.CheckConstraint("CK_UserSettings_Longitude_Range", "\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)");
34-
table.CheckConstraint("CK_UserSettings_MileageRateCents_Positive", "\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0");
35-
table.CheckConstraint("CK_UserSettings_OilChangePrice_Positive", "\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0");
36-
table.CheckConstraint("CK_UserSettings_YearlyGoalMiles_Positive", "\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0");
39+
table.CheckConstraint(
40+
"CK_UserSettings_AverageCarMpg_Positive",
41+
"\"AverageCarMpg\" IS NULL OR CAST(\"AverageCarMpg\" AS REAL) > 0"
42+
);
43+
table.CheckConstraint(
44+
"CK_UserSettings_Latitude_Range",
45+
"\"Latitude\" IS NULL OR (CAST(\"Latitude\" AS REAL) >= -90 AND CAST(\"Latitude\" AS REAL) <= 90)"
46+
);
47+
table.CheckConstraint(
48+
"CK_UserSettings_Longitude_Range",
49+
"\"Longitude\" IS NULL OR (CAST(\"Longitude\" AS REAL) >= -180 AND CAST(\"Longitude\" AS REAL) <= 180)"
50+
);
51+
table.CheckConstraint(
52+
"CK_UserSettings_MileageRateCents_Positive",
53+
"\"MileageRateCents\" IS NULL OR CAST(\"MileageRateCents\" AS REAL) > 0"
54+
);
55+
table.CheckConstraint(
56+
"CK_UserSettings_OilChangePrice_Positive",
57+
"\"OilChangePrice\" IS NULL OR CAST(\"OilChangePrice\" AS REAL) > 0"
58+
);
59+
table.CheckConstraint(
60+
"CK_UserSettings_YearlyGoalMiles_Positive",
61+
"\"YearlyGoalMiles\" IS NULL OR CAST(\"YearlyGoalMiles\" AS REAL) > 0"
62+
);
3763
table.ForeignKey(
3864
name: "FK_UserSettings_Users_UserId",
3965
column: x => x.UserId,
4066
principalTable: "Users",
4167
principalColumn: "UserId",
42-
onDelete: ReferentialAction.Cascade);
43-
});
68+
onDelete: ReferentialAction.Cascade
69+
);
70+
}
71+
);
4472
}
4573

4674
/// <inheritdoc />
4775
protected override void Down(MigrationBuilder migrationBuilder)
4876
{
49-
migrationBuilder.DropTable(
50-
name: "UserSettings");
77+
migrationBuilder.DropTable(name: "UserSettings");
5178
}
5279
}
5380
}

0 commit comments

Comments
 (0)