Implement CachedTransportService design with interface separation
Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>
This commit is contained in:
parent
c8e5904775
commit
47725a13bd
7 changed files with 187 additions and 96 deletions
|
|
@ -11,14 +11,14 @@ namespace ShiftScheduler.Server.Controllers
|
|||
private readonly ShiftService _shiftService;
|
||||
private readonly IcsExportService _icsService;
|
||||
private readonly PdfExportService _pdfExportService;
|
||||
private readonly TransportService _transportService;
|
||||
private readonly ITransportService _transportService;
|
||||
private readonly TransportConfiguration _transportConfig;
|
||||
|
||||
public ShiftController(
|
||||
ShiftService shiftService,
|
||||
IcsExportService icsService,
|
||||
PdfExportService pdfExportService,
|
||||
TransportService transportService,
|
||||
ITransportService transportService,
|
||||
TransportConfiguration transportConfig)
|
||||
{
|
||||
_shiftService = shiftService;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ builder.Services.AddHttpClient<TransportService>();
|
|||
builder.Services.AddSingleton<ShiftService>();
|
||||
builder.Services.AddSingleton<IcsExportService>();
|
||||
builder.Services.AddSingleton<PdfExportService>();
|
||||
builder.Services.AddSingleton<TransportService>();
|
||||
builder.Services.AddSingleton<ITransportApiService, TransportService>();
|
||||
builder.Services.AddSingleton<ITransportService, CachedTransportService>();
|
||||
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddRazorPages();
|
||||
|
|
|
|||
43
Services/CachedTransportService.cs
Normal file
43
Services/CachedTransportService.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using Microsoft.Extensions.Caching.Memory;
|
||||
using ShiftScheduler.Shared;
|
||||
|
||||
namespace ShiftScheduler.Services
|
||||
{
|
||||
public class CachedTransportService(ITransportApiService transportService, TransportConfiguration config, IMemoryCache cache) : ITransportService
|
||||
{
|
||||
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime)
|
||||
{
|
||||
var searchDate = shiftStartTime.ToString("yyyy-MM-dd");
|
||||
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 TransportConnection? cachedConnection) && cachedConnection != null)
|
||||
{
|
||||
return cachedConnection;
|
||||
}
|
||||
|
||||
// Not in cache, call the underlying transport service
|
||||
var connection = await transportService.GetConnectionAsync(shiftStartTime);
|
||||
|
||||
// Cache the connection result if valid
|
||||
if (connection != null)
|
||||
{
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(config.CacheDurationDays)
|
||||
};
|
||||
cache.Set(cacheKey, connection, cacheOptions);
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
private string GenerateCacheKey(string searchDate, string searchTime)
|
||||
{
|
||||
return $"transport_{config.StartStation}_{config.EndStation}_{searchDate}_{searchTime}";
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Services/ITransportService.cs
Normal file
14
Services/ITransportService.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
using ShiftScheduler.Shared;
|
||||
|
||||
namespace ShiftScheduler.Services
|
||||
{
|
||||
public interface ITransportService
|
||||
{
|
||||
Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime);
|
||||
}
|
||||
|
||||
public interface ITransportApiService
|
||||
{
|
||||
Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using ShiftScheduler.Shared;
|
||||
|
||||
namespace ShiftScheduler.Services
|
||||
{
|
||||
public class TransportService(HttpClient httpClient, TransportConfiguration config, IMemoryCache cache)
|
||||
public class TransportService(HttpClient httpClient, TransportConfiguration config) : ITransportApiService
|
||||
{
|
||||
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime)
|
||||
{
|
||||
|
|
@ -15,16 +14,6 @@ namespace ShiftScheduler.Services
|
|||
// and request more connections to cover the full range
|
||||
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 response = await httpClient.GetStringAsync(url);
|
||||
var apiResponse = JsonSerializer.Deserialize<TransportApiResponse>(response, new JsonSerializerOptions
|
||||
|
|
@ -32,26 +21,6 @@ namespace ShiftScheduler.Services
|
|||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
// Cache the response if valid
|
||||
if (apiResponse != null)
|
||||
{
|
||||
var cacheOptions = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(config.CacheDurationDays)
|
||||
};
|
||||
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)
|
||||
{
|
||||
var allConnections = apiResponse.Connections.Select(MapToTransportConnection).ToList();
|
||||
|
|
|
|||
124
ShiftScheduler.Services.Tests/CachedTransportServiceTests.cs
Normal file
124
ShiftScheduler.Services.Tests/CachedTransportServiceTests.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Moq;
|
||||
using ShiftScheduler.Services;
|
||||
using ShiftScheduler.Shared;
|
||||
using Shouldly;
|
||||
|
||||
namespace ShiftScheduler.Services.Tests;
|
||||
|
||||
public class CachedTransportServiceTests
|
||||
{
|
||||
private readonly Mock<ITransportApiService> _transportServiceMock;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly CachedTransportService _cachedTransportService;
|
||||
private readonly TransportConfiguration _config;
|
||||
|
||||
public CachedTransportServiceTests()
|
||||
{
|
||||
_transportServiceMock = new Mock<ITransportApiService>();
|
||||
_memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_config = new TransportConfiguration
|
||||
{
|
||||
StartStation = "Zurich HB",
|
||||
EndStation = "Bern",
|
||||
ApiBaseUrl = "https://transport.opendata.ch/v1",
|
||||
SafetyBufferMinutes = 30,
|
||||
MinBreakMinutes = 60,
|
||||
MaxEarlyArrivalMinutes = 60,
|
||||
MaxLateArrivalMinutes = 15,
|
||||
CacheDurationDays = 1
|
||||
};
|
||||
|
||||
_cachedTransportService = new CachedTransportService(_transportServiceMock.Object, _config, _memoryCache);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionAsync_WithValidConnection_ShouldCacheResult()
|
||||
{
|
||||
// Arrange
|
||||
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
|
||||
var connection = new TransportConnection
|
||||
{
|
||||
DepartureTime = "2023-12-15T06:45:00",
|
||||
ArrivalTime = "2023-12-15T07:30:00",
|
||||
Duration = "00:45:00",
|
||||
Platform = "5"
|
||||
};
|
||||
|
||||
_transportServiceMock
|
||||
.Setup(x => x.GetConnectionAsync(shiftStartTime))
|
||||
.ReturnsAsync(connection);
|
||||
|
||||
// Act - First call should hit the transport service
|
||||
var result1 = await _cachedTransportService.GetConnectionAsync(shiftStartTime);
|
||||
|
||||
// Act - Second call should use cache
|
||||
var result2 = await _cachedTransportService.GetConnectionAsync(shiftStartTime);
|
||||
|
||||
// Assert
|
||||
result1.ShouldNotBeNull();
|
||||
result2.ShouldNotBeNull();
|
||||
result1.ArrivalTime.ShouldBe(result2.ArrivalTime);
|
||||
result1.DepartureTime.ShouldBe(result2.DepartureTime);
|
||||
|
||||
// Verify that transport service was called only once
|
||||
_transportServiceMock.Verify(x => x.GetConnectionAsync(shiftStartTime), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionAsync_WithNullConnection_ShouldNotCache()
|
||||
{
|
||||
// Arrange
|
||||
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
|
||||
|
||||
_transportServiceMock
|
||||
.Setup(x => x.GetConnectionAsync(shiftStartTime))
|
||||
.ReturnsAsync((TransportConnection?)null);
|
||||
|
||||
// Act - First call
|
||||
var result1 = await _cachedTransportService.GetConnectionAsync(shiftStartTime);
|
||||
|
||||
// Act - Second call should call transport service again since null wasn't cached
|
||||
var result2 = await _cachedTransportService.GetConnectionAsync(shiftStartTime);
|
||||
|
||||
// Assert
|
||||
result1.ShouldBeNull();
|
||||
result2.ShouldBeNull();
|
||||
|
||||
// Verify that transport service was called twice (no caching for null)
|
||||
_transportServiceMock.Verify(x => x.GetConnectionAsync(shiftStartTime), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConnectionAsync_WithDifferentDates_ShouldCreateSeparateCacheEntries()
|
||||
{
|
||||
// Arrange
|
||||
var shiftStartTime1 = new DateTime(2023, 12, 15, 8, 0, 0);
|
||||
var shiftStartTime2 = new DateTime(2023, 12, 16, 8, 0, 0);
|
||||
|
||||
var connection1 = new TransportConnection { ArrivalTime = "2023-12-15T07:30:00" };
|
||||
var connection2 = new TransportConnection { ArrivalTime = "2023-12-16T07:30:00" };
|
||||
|
||||
_transportServiceMock
|
||||
.Setup(x => x.GetConnectionAsync(shiftStartTime1))
|
||||
.ReturnsAsync(connection1);
|
||||
|
||||
_transportServiceMock
|
||||
.Setup(x => x.GetConnectionAsync(shiftStartTime2))
|
||||
.ReturnsAsync(connection2);
|
||||
|
||||
// Act - Different dates should result in different cache keys
|
||||
var result1 = await _cachedTransportService.GetConnectionAsync(shiftStartTime1);
|
||||
var result2 = await _cachedTransportService.GetConnectionAsync(shiftStartTime2);
|
||||
|
||||
// Assert
|
||||
result1.ShouldNotBeNull();
|
||||
result2.ShouldNotBeNull();
|
||||
result1.ArrivalTime.ShouldBe("2023-12-15T07:30:00");
|
||||
result2.ArrivalTime.ShouldBe("2023-12-16T07:30:00");
|
||||
|
||||
// Verify that transport service was called twice (different cache keys)
|
||||
_transportServiceMock.Verify(x => x.GetConnectionAsync(It.IsAny<DateTime>()), Times.Exactly(2));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using ShiftScheduler.Services;
|
||||
|
|
@ -13,14 +12,12 @@ public class TransportServiceTests
|
|||
{
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly TransportService _transportService;
|
||||
|
||||
public TransportServiceTests()
|
||||
{
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
var config = new TransportConfiguration
|
||||
{
|
||||
|
|
@ -34,7 +31,7 @@ public class TransportServiceTests
|
|||
CacheDurationDays = 1
|
||||
};
|
||||
|
||||
_transportService = new TransportService(_httpClient, config, _memoryCache);
|
||||
_transportService = new TransportService(_httpClient, config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -119,63 +116,6 @@ public class TransportServiceTests
|
|||
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)
|
||||
{
|
||||
_httpMessageHandlerMock.Protected()
|
||||
|
|
|
|||
Loading…
Reference in a new issue