Implement caching for transport connection results with IMemoryCache

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-08-24 06:52:55 +00:00
parent b209378c67
commit be741ca787
6 changed files with 104 additions and 10 deletions

View file

@ -10,6 +10,7 @@ var transportConfig = builder.Configuration.GetSection("Transport").Get<Transpor
// Register services // Register services
builder.Services.AddSingleton(shifts); builder.Services.AddSingleton(shifts);
builder.Services.AddSingleton(transportConfig); builder.Services.AddSingleton(transportConfig);
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<TransportService>(); builder.Services.AddHttpClient<TransportService>();
builder.Services.AddSingleton<ShiftService>(); builder.Services.AddSingleton<ShiftService>();
builder.Services.AddSingleton<IcsExportService>(); builder.Services.AddSingleton<IcsExportService>();

View file

@ -14,7 +14,8 @@
"SafetyBufferMinutes": 30, "SafetyBufferMinutes": 30,
"MinBreakMinutes": 60, "MinBreakMinutes": 60,
"MaxEarlyArrivalMinutes": 60, "MaxEarlyArrivalMinutes": 60,
"MaxLateArrivalMinutes": 15 "MaxLateArrivalMinutes": 15,
"CacheDurationMinutes": 15
}, },
"Shifts": [ "Shifts": [

View file

@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Ical.Net" Version="5.1.0" /> <PackageReference Include="Ical.Net" Version="5.1.0" />
<PackageReference Include="QuestPDF" Version="2025.7.0" /> <PackageReference Include="QuestPDF" Version="2025.7.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,9 +1,10 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using ShiftScheduler.Shared; using ShiftScheduler.Shared;
namespace ShiftScheduler.Services namespace ShiftScheduler.Services
{ {
public class TransportService(HttpClient httpClient, TransportConfiguration config) public class TransportService(HttpClient httpClient, TransportConfiguration config, IMemoryCache cache)
{ {
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime) public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime)
{ {
@ -14,6 +15,16 @@ namespace ShiftScheduler.Services
// and request more connections to cover the full range // and request more connections to cover the full range
var searchTime = shiftStartTime.AddMinutes(config.MaxLateArrivalMinutes).ToString("HH:mm"); var searchTime = shiftStartTime.AddMinutes(config.MaxLateArrivalMinutes).ToString("HH:mm");
// Generate cache key based on request parameters
var cacheKey = GenerateCacheKey(searchDate, searchTime);
// Try to get from cache first
if (cache.TryGetValue(cacheKey, out TransportApiResponse? cachedResponse) && cachedResponse != null)
{
return ProcessApiResponse(cachedResponse, shiftStartTime);
}
// Not in cache, make API call
var url = $"{config.ApiBaseUrl}/connections?from={Uri.EscapeDataString(config.StartStation)}&to={Uri.EscapeDataString(config.EndStation)}&date={searchDate}&time={searchTime}&isArrivalTime=1&limit=5"; var url = $"{config.ApiBaseUrl}/connections?from={Uri.EscapeDataString(config.StartStation)}&to={Uri.EscapeDataString(config.EndStation)}&date={searchDate}&time={searchTime}&isArrivalTime=1&limit=5";
var response = await httpClient.GetStringAsync(url); var response = await httpClient.GetStringAsync(url);
var apiResponse = JsonSerializer.Deserialize<TransportApiResponse>(response, new JsonSerializerOptions var apiResponse = JsonSerializer.Deserialize<TransportApiResponse>(response, new JsonSerializerOptions
@ -21,6 +32,26 @@ namespace ShiftScheduler.Services
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}); });
// Cache the response if valid
if (apiResponse != null)
{
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(config.CacheDurationMinutes)
};
cache.Set(cacheKey, apiResponse, cacheOptions);
}
return ProcessApiResponse(apiResponse, shiftStartTime);
}
private string GenerateCacheKey(string searchDate, string searchTime)
{
return $"transport_{config.StartStation}_{config.EndStation}_{searchDate}_{searchTime}";
}
private TransportConnection? ProcessApiResponse(TransportApiResponse? apiResponse, DateTime shiftStartTime)
{
if (apiResponse?.Connections.Count > 0) if (apiResponse?.Connections.Count > 0)
{ {
var allConnections = apiResponse.Connections.Select(MapToTransportConnection).ToList(); var allConnections = apiResponse.Connections.Select(MapToTransportConnection).ToList();

View file

@ -9,6 +9,7 @@ namespace ShiftScheduler.Shared
public int MinBreakMinutes { get; set; } = 60; public int MinBreakMinutes { get; set; } = 60;
public int MaxEarlyArrivalMinutes { get; set; } = 60; public int MaxEarlyArrivalMinutes { get; set; } = 60;
public int MaxLateArrivalMinutes { get; set; } = 15; public int MaxLateArrivalMinutes { get; set; } = 15;
public int CacheDurationMinutes { get; set; } = 15;
} }
public class TransportConnection public class TransportConnection

View file

@ -1,5 +1,6 @@
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Moq; using Moq;
using Moq.Protected; using Moq.Protected;
using ShiftScheduler.Services; using ShiftScheduler.Services;
@ -12,12 +13,14 @@ public class TransportServiceTests
{ {
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock; private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly IMemoryCache _memoryCache;
private readonly TransportService _transportService; private readonly TransportService _transportService;
public TransportServiceTests() public TransportServiceTests()
{ {
_httpMessageHandlerMock = new Mock<HttpMessageHandler>(); _httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClient = new HttpClient(_httpMessageHandlerMock.Object); _httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_memoryCache = new MemoryCache(new MemoryCacheOptions());
var config = new TransportConfiguration var config = new TransportConfiguration
{ {
@ -27,10 +30,11 @@ public class TransportServiceTests
SafetyBufferMinutes = 30, SafetyBufferMinutes = 30,
MinBreakMinutes = 60, MinBreakMinutes = 60,
MaxEarlyArrivalMinutes = 60, MaxEarlyArrivalMinutes = 60,
MaxLateArrivalMinutes = 15 MaxLateArrivalMinutes = 15,
CacheDurationMinutes = 15
}; };
_transportService = new TransportService(_httpClient, config); _transportService = new TransportService(_httpClient, config, _memoryCache);
} }
[Fact] [Fact]
@ -115,19 +119,74 @@ public class TransportServiceTests
result.ArrivalTime.ShouldBe("2023-12-15T07:25:00"); result.ArrivalTime.ShouldBe("2023-12-15T07:25:00");
} }
[Fact]
public async Task GetConnectionAsync_WithCaching_ShouldCacheApiResponse()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
var apiResponse = CreateValidApiResponse();
var jsonResponse = JsonSerializer.Serialize(apiResponse);
SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse);
// Act - First call should hit the API
var result1 = await _transportService.GetConnectionAsync(shiftStartTime);
// Act - Second call should use cache
var result2 = await _transportService.GetConnectionAsync(shiftStartTime);
// Assert
result1.ShouldNotBeNull();
result2.ShouldNotBeNull();
result1.ArrivalTime.ShouldBe(result2.ArrivalTime);
result1.DepartureTime.ShouldBe(result2.DepartureTime);
// Verify that HTTP call was made only once
_httpMessageHandlerMock.Protected().Verify(
"SendAsync",
Times.Once(),
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>());
}
[Fact]
public async Task GetConnectionAsync_WithDifferentDates_ShouldMakeSeparateApiCalls()
{
// Arrange
var shiftStartTime1 = new DateTime(2023, 12, 15, 8, 0, 0);
var shiftStartTime2 = new DateTime(2023, 12, 16, 8, 0, 0);
var apiResponse = CreateValidApiResponse();
var jsonResponse = JsonSerializer.Serialize(apiResponse);
SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse);
// Act - Different dates should result in different cache keys
var result1 = await _transportService.GetConnectionAsync(shiftStartTime1);
var result2 = await _transportService.GetConnectionAsync(shiftStartTime2);
// Assert
result1.ShouldNotBeNull();
result2.ShouldNotBeNull();
// Verify that HTTP calls were made twice (different cache keys)
_httpMessageHandlerMock.Protected().Verify(
"SendAsync",
Times.Exactly(2),
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>());
}
private void SetupHttpMockResponse(HttpStatusCode statusCode, string content) private void SetupHttpMockResponse(HttpStatusCode statusCode, string content)
{ {
var response = new HttpResponseMessage(statusCode)
{
Content = new StringContent(content)
};
_httpMessageHandlerMock.Protected() _httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>( .Setup<Task<HttpResponseMessage>>(
"SendAsync", "SendAsync",
ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()) ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response); .ReturnsAsync(() => new HttpResponseMessage(statusCode)
{
Content = new StringContent(content)
});
} }
private static TransportApiResponse CreateValidApiResponse() private static TransportApiResponse CreateValidApiResponse()