Implement caching for transport connection results with IMemoryCache
Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>
This commit is contained in:
parent
b209378c67
commit
be741ca787
6 changed files with 104 additions and 10 deletions
|
|
@ -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>();
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
"SafetyBufferMinutes": 30,
|
"SafetyBufferMinutes": 30,
|
||||||
"MinBreakMinutes": 60,
|
"MinBreakMinutes": 60,
|
||||||
"MaxEarlyArrivalMinutes": 60,
|
"MaxEarlyArrivalMinutes": 60,
|
||||||
"MaxLateArrivalMinutes": 15
|
"MaxLateArrivalMinutes": 15,
|
||||||
|
"CacheDurationMinutes": 15
|
||||||
},
|
},
|
||||||
|
|
||||||
"Shifts": [
|
"Shifts": [
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue