Skip to content

Commit e601e63

Browse files
author
aligneddev
committed
feat: Add gas price tracking to ride records and implement gas price lookup service
- Enhanced the HistoryPage to include gas price input and display. - Updated RecordRidePage to support gas price entry and validation. - Implemented a new GasPriceLookupService for fetching gas prices from an external API. - Created a GasPriceLookupEntity for caching gas prices in the database. - Added migrations to include gas price fields in the Rides table and create a GasPriceLookups table. - Updated tests to cover gas price functionality in both RecordRidePage and HistoryPage.
1 parent 77378f0 commit e601e63

31 files changed

Lines changed: 1571 additions & 884 deletions

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ When a user opens the edit form for an existing ride, the gas price field is pre
5353

5454
1. **Given** a user opens the edit form for a ride, **When** the form loads, **Then** the gas price field is pre-populated with the gas price stored on that ride.
5555
2. **Given** the user changes the ride date to a new date with an available EIA price, **When** the date changes, **Then** the gas price field updates to the price for the new date.
56-
3. **Given** the user changes the ride date to a date with no EIA price available, **When** the date changes, **Then** the gas price field falls back to the most recent prior ride's gas price.
56+
3. **Given** the user changes the ride date to a date with no EIA price available, **When** the date changes, **Then** the gas price field retains its current value (the previously shown price, whether from a prior EIA lookup, the fallback, or a user edit).
5757
4. **Given** the user changes the ride date and the gas price field updates, **When** the user manually overwrites the field and saves, **Then** the user-entered value is stored in the ride updated event.
5858
5. **Given** the user edits a ride without changing the date, **When** the form is submitted, **Then** the existing gas price value is preserved unchanged.
5959

@@ -104,8 +104,8 @@ When a gas price has already been fetched for a given date, any subsequent form
104104
### Key Entities
105105

106106
- **GasPriceLookup**: Represents a single gas price retrieved for a calendar date. Key attributes: date (calendar date), price per gallon (decimal, USD), data source identifier, retrieved-at timestamp. One record per date; acts as the durable cache entry.
107-
- **RideCreatedEvent**: Extended to include an optional gas price per gallon (USD) at the time of the ride's date.
108-
- **RideUpdatedEvent**: Extended to include an optional gas price per gallon (USD) reflecting the price for the (possibly new) ride date.
107+
- **RideRecordedEventPayload**: Extended to include an optional gas price per gallon (USD) at the time of the ride's date.
108+
- **RideEditedEventPayload**: Extended to include an optional gas price per gallon (USD) reflecting the price for the (possibly new) ride date.
109109

110110
## Success Criteria *(mandatory)*
111111

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

Lines changed: 86 additions & 55 deletions
Large diffs are not rendered by default.
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
using System.Net;
2+
using System.Text;
3+
using BikeTracking.Api.Application.Rides;
4+
using BikeTracking.Api.Infrastructure.Persistence;
5+
using BikeTracking.Api.Infrastructure.Persistence.Entities;
6+
using Microsoft.Data.Sqlite;
7+
using Microsoft.EntityFrameworkCore;
8+
using Microsoft.Extensions.Configuration;
9+
using Microsoft.Extensions.Logging.Abstractions;
10+
11+
namespace BikeTracking.Api.Tests.Application;
12+
13+
public sealed class GasPriceLookupServiceTests
14+
{
15+
[Fact]
16+
public async Task GetOrFetchAsync_CacheHit_DoesNotCallHttp()
17+
{
18+
await using var connection = new SqliteConnection("DataSource=:memory:");
19+
await connection.OpenAsync();
20+
21+
await using var context = CreateSqliteContext(connection);
22+
await context.Database.EnsureCreatedAsync();
23+
context.GasPriceLookups.Add(
24+
new GasPriceLookupEntity
25+
{
26+
PriceDate = new DateOnly(2026, 3, 31),
27+
PricePerGallon = 3.1999m,
28+
DataSource = "EIA_EPM0_NUS_Weekly",
29+
EiaPeriodDate = new DateOnly(2026, 3, 30),
30+
RetrievedAtUtc = DateTime.UtcNow,
31+
}
32+
);
33+
await context.SaveChangesAsync();
34+
35+
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
36+
{
37+
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
38+
});
39+
var factory = new StubHttpClientFactory(
40+
new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") }
41+
);
42+
var config = new ConfigurationBuilder()
43+
.AddInMemoryCollection(
44+
new Dictionary<string, string?> { ["GasPriceLookup:EiaApiKey"] = "fake-key" }
45+
)
46+
.Build();
47+
48+
var service = new EiaGasPriceLookupService(
49+
context,
50+
factory,
51+
config,
52+
NullLogger<EiaGasPriceLookupService>.Instance
53+
);
54+
55+
var result = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31));
56+
57+
Assert.Equal(3.1999m, result);
58+
Assert.Equal(0, handler.CallCount);
59+
}
60+
61+
[Fact]
62+
public async Task GetOrFetchAsync_CacheMiss_FetchesAndStores()
63+
{
64+
await using var connection = new SqliteConnection("DataSource=:memory:");
65+
await connection.OpenAsync();
66+
67+
await using var context = CreateSqliteContext(connection);
68+
await context.Database.EnsureCreatedAsync();
69+
70+
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
71+
{
72+
Content = new StringContent(
73+
"""
74+
{"response":{"data":[{"period":"2026-03-30","value":"3.125"}]}}
75+
""",
76+
Encoding.UTF8,
77+
"application/json"
78+
),
79+
});
80+
81+
var factory = new StubHttpClientFactory(
82+
new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") }
83+
);
84+
var config = new ConfigurationBuilder()
85+
.AddInMemoryCollection(
86+
new Dictionary<string, string?> { ["GasPriceLookup:EiaApiKey"] = "fake-key" }
87+
)
88+
.Build();
89+
90+
var service = new EiaGasPriceLookupService(
91+
context,
92+
factory,
93+
config,
94+
NullLogger<EiaGasPriceLookupService>.Instance
95+
);
96+
97+
var result = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31));
98+
99+
Assert.Equal(3.125m, result);
100+
Assert.Equal(1, handler.CallCount);
101+
102+
var cached = await context.GasPriceLookups.SingleAsync();
103+
Assert.Equal(new DateOnly(2026, 3, 31), cached.PriceDate);
104+
Assert.Equal(3.125m, cached.PricePerGallon);
105+
}
106+
107+
[Fact]
108+
public async Task GetOrFetchAsync_OnHttpFailure_ReturnsNullAndDoesNotWrite()
109+
{
110+
await using var connection = new SqliteConnection("DataSource=:memory:");
111+
await connection.OpenAsync();
112+
113+
await using var context = CreateSqliteContext(connection);
114+
await context.Database.EnsureCreatedAsync();
115+
116+
var handler = new StubHandler(_ => new HttpResponseMessage(
117+
HttpStatusCode.InternalServerError
118+
));
119+
var factory = new StubHttpClientFactory(
120+
new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") }
121+
);
122+
var config = new ConfigurationBuilder()
123+
.AddInMemoryCollection(
124+
new Dictionary<string, string?> { ["GasPriceLookup:EiaApiKey"] = "fake-key" }
125+
)
126+
.Build();
127+
128+
var service = new EiaGasPriceLookupService(
129+
context,
130+
factory,
131+
config,
132+
NullLogger<EiaGasPriceLookupService>.Instance
133+
);
134+
135+
var result = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31));
136+
137+
Assert.Null(result);
138+
Assert.Equal(0, await context.GasPriceLookups.CountAsync());
139+
}
140+
141+
[Fact]
142+
public async Task GetOrFetchAsync_SecondCall_UsesCacheAndCallsHttpOnce()
143+
{
144+
await using var connection = new SqliteConnection("DataSource=:memory:");
145+
await connection.OpenAsync();
146+
147+
await using var context = CreateSqliteContext(connection);
148+
await context.Database.EnsureCreatedAsync();
149+
150+
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
151+
{
152+
Content = new StringContent(
153+
"""
154+
{"response":{"data":[{"period":"2026-03-30","value":"3.125"}]}}
155+
""",
156+
Encoding.UTF8,
157+
"application/json"
158+
),
159+
});
160+
var factory = new StubHttpClientFactory(
161+
new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") }
162+
);
163+
var config = new ConfigurationBuilder()
164+
.AddInMemoryCollection(
165+
new Dictionary<string, string?> { ["GasPriceLookup:EiaApiKey"] = "fake-key" }
166+
)
167+
.Build();
168+
169+
var service = new EiaGasPriceLookupService(
170+
context,
171+
factory,
172+
config,
173+
NullLogger<EiaGasPriceLookupService>.Instance
174+
);
175+
176+
var first = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31));
177+
var second = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31));
178+
179+
Assert.Equal(3.125m, first);
180+
Assert.Equal(3.125m, second);
181+
Assert.Equal(1, handler.CallCount);
182+
}
183+
184+
[Fact]
185+
public async Task GetOrFetchAsync_WhenDuplicateDateInsertedConcurrently_ReturnsCachedValue()
186+
{
187+
await using var connection = new SqliteConnection("DataSource=:memory:");
188+
await connection.OpenAsync();
189+
190+
await using (var setupContext = CreateSqliteContext(connection))
191+
{
192+
await setupContext.Database.EnsureCreatedAsync();
193+
}
194+
195+
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
196+
{
197+
Content = new StringContent(
198+
"""
199+
{"response":{"data":[{"period":"2026-03-30","value":"3.125"}]}}
200+
""",
201+
Encoding.UTF8,
202+
"application/json"
203+
),
204+
});
205+
var factory = new StubHttpClientFactory(
206+
new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") }
207+
);
208+
var config = new ConfigurationBuilder()
209+
.AddInMemoryCollection(
210+
new Dictionary<string, string?> { ["GasPriceLookup:EiaApiKey"] = "fake-key" }
211+
)
212+
.Build();
213+
214+
await using var context = CreateSqliteContext(connection);
215+
var service = new EiaGasPriceLookupService(
216+
context,
217+
factory,
218+
config,
219+
NullLogger<EiaGasPriceLookupService>.Instance
220+
);
221+
222+
context.SavingChanges += (_, _) =>
223+
{
224+
if (context.GasPriceLookups.Local.Any(x => x.PriceDate == new DateOnly(2026, 3, 31)))
225+
{
226+
using var concurrentContext = CreateSqliteContext(connection);
227+
concurrentContext.GasPriceLookups.Add(
228+
new GasPriceLookupEntity
229+
{
230+
PriceDate = new DateOnly(2026, 3, 31),
231+
PricePerGallon = 3.2222m,
232+
DataSource = "EIA_EPM0_NUS_Weekly",
233+
EiaPeriodDate = new DateOnly(2026, 3, 30),
234+
RetrievedAtUtc = DateTime.UtcNow,
235+
}
236+
);
237+
concurrentContext.SaveChanges();
238+
}
239+
};
240+
241+
var result = await service.GetOrFetchAsync(new DateOnly(2026, 3, 31));
242+
243+
Assert.Equal(3.2222m, result);
244+
Assert.Equal(1, handler.CallCount);
245+
}
246+
247+
[Fact]
248+
public async Task GetOrFetchAsync_AfterServiceRestart_UsesPersistedCacheWithoutHttp()
249+
{
250+
await using var connection = new SqliteConnection("DataSource=:memory:");
251+
await connection.OpenAsync();
252+
253+
await using (var setupContext = CreateSqliteContext(connection))
254+
{
255+
await setupContext.Database.EnsureCreatedAsync();
256+
setupContext.GasPriceLookups.Add(
257+
new GasPriceLookupEntity
258+
{
259+
PriceDate = new DateOnly(2026, 3, 31),
260+
PricePerGallon = 3.4567m,
261+
DataSource = "EIA_EPM0_NUS_Weekly",
262+
EiaPeriodDate = new DateOnly(2026, 3, 30),
263+
RetrievedAtUtc = DateTime.UtcNow,
264+
}
265+
);
266+
await setupContext.SaveChangesAsync();
267+
}
268+
269+
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
270+
{
271+
Content = new StringContent(
272+
"""
273+
{"response":{"data":[{"period":"2026-03-30","value":"3.125"}]}}
274+
""",
275+
Encoding.UTF8,
276+
"application/json"
277+
),
278+
});
279+
var factory = new StubHttpClientFactory(
280+
new HttpClient(handler) { BaseAddress = new Uri("https://api.eia.gov") }
281+
);
282+
var config = new ConfigurationBuilder()
283+
.AddInMemoryCollection(
284+
new Dictionary<string, string?> { ["GasPriceLookup:EiaApiKey"] = "fake-key" }
285+
)
286+
.Build();
287+
288+
await using var restartedContext = CreateSqliteContext(connection);
289+
var restartedService = new EiaGasPriceLookupService(
290+
restartedContext,
291+
factory,
292+
config,
293+
NullLogger<EiaGasPriceLookupService>.Instance
294+
);
295+
296+
var result = await restartedService.GetOrFetchAsync(new DateOnly(2026, 3, 31));
297+
298+
Assert.Equal(3.4567m, result);
299+
Assert.Equal(0, handler.CallCount);
300+
}
301+
302+
private static BikeTrackingDbContext CreateSqliteContext(SqliteConnection connection)
303+
{
304+
var options = new DbContextOptionsBuilder<BikeTrackingDbContext>()
305+
.UseSqlite(connection)
306+
.Options;
307+
308+
return new BikeTrackingDbContext(options);
309+
}
310+
311+
private sealed class StubHttpClientFactory(HttpClient client) : IHttpClientFactory
312+
{
313+
public HttpClient CreateClient(string name) => client;
314+
}
315+
316+
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
317+
: HttpMessageHandler
318+
{
319+
public int CallCount { get; private set; }
320+
321+
protected override Task<HttpResponseMessage> SendAsync(
322+
HttpRequestMessage request,
323+
CancellationToken cancellationToken
324+
)
325+
{
326+
CallCount += 1;
327+
return Task.FromResult(handler(request));
328+
}
329+
}
330+
}

0 commit comments

Comments
 (0)