Implement caching for transport connection results with IMemoryCache (#26)

* Initial plan

* Implement caching for transport connection results with IMemoryCache

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Change cache duration from minutes to days

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Implement CachedTransportService design with interface separation

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Implement persistent file-based cache to survive application restarts

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Revert persistent file-based cache implementation, restore IMemoryCache usage

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Code cleanup

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>
Co-authored-by: Claudio Schaad <c.schaad@pog.ch>
This commit is contained in:
Copilot 2025-08-25 21:15:13 +02:00 committed by GitHub
parent 68e2fa4927
commit 73abfac988
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 504 additions and 321 deletions

View file

@ -11,14 +11,14 @@ namespace ShiftScheduler.Server.Controllers
private readonly ShiftService _shiftService; private readonly ShiftService _shiftService;
private readonly IcsExportService _icsService; private readonly IcsExportService _icsService;
private readonly PdfExportService _pdfExportService; private readonly PdfExportService _pdfExportService;
private readonly TransportService _transportService; private readonly ITransportService _transportService;
private readonly TransportConfiguration _transportConfig; private readonly TransportConfiguration _transportConfig;
public ShiftController( public ShiftController(
ShiftService shiftService, ShiftService shiftService,
IcsExportService icsService, IcsExportService icsService,
PdfExportService pdfExportService, PdfExportService pdfExportService,
TransportService transportService, ITransportService transportService,
TransportConfiguration transportConfig) TransportConfiguration transportConfig)
{ {
_shiftService = shiftService; _shiftService = shiftService;

View file

@ -10,11 +10,13 @@ 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.AddHttpClient<TransportService>(); builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<TransportApiService>();
builder.Services.AddSingleton<ShiftService>(); builder.Services.AddSingleton<ShiftService>();
builder.Services.AddSingleton<IcsExportService>(); builder.Services.AddSingleton<IcsExportService>();
builder.Services.AddSingleton<PdfExportService>(); builder.Services.AddSingleton<PdfExportService>();
builder.Services.AddSingleton<TransportService>(); builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
builder.Services.AddSingleton<ITransportService, TransportService>();
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();

View file

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

View 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);
}
}

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

@ -0,0 +1,133 @@
using System.Text.Json;
using ShiftScheduler.Shared;
namespace ShiftScheduler.Services
{
public class TransportApiService(HttpClient httpClient, TransportConfiguration config) : ITransportApiService
{
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime)
{
// To allow connections that arrive after shift starts, we search from earlier time
// and request more connections to cover the full range
var searchDate = shiftStartTime.ToString("yyyy-MM-dd");
var searchTime = shiftStartTime.AddMinutes(config.MaxLateArrivalMinutes).ToString("HH:mm");
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
{
PropertyNameCaseInsensitive = true
});
if (apiResponse?.Connections.Count > 0)
{
var allConnections = apiResponse.Connections.Select(MapToTransportConnection).ToList();
return TransportConnectionCalculator.FindBestConnection(
allConnections,
shiftStartTime,
config.SafetyBufferMinutes,
config.MaxEarlyArrivalMinutes,
config.MaxLateArrivalMinutes);
}
return null;
}
private TransportConnection MapToTransportConnection(TransportApiConnection? apiConnection)
{
if (apiConnection == null)
return new TransportConnection();
return new TransportConnection
{
DepartureTime = apiConnection.From?.Departure ?? string.Empty,
ArrivalTime = apiConnection.To?.Arrival ?? string.Empty,
Duration = apiConnection.Duration,
Platform = apiConnection.From?.Platform ?? string.Empty,
Sections = apiConnection.Sections.Select(s => new TransportSection
{
Journey = s.Journey != null ? new TransportJourney
{
Name = s.Journey.Name,
Category = s.Journey.Category,
Number = s.Journey.Number
} : null,
Departure = s.Departure != null ? new TransportCheckpoint
{
Station = s.Departure.Station != null ? new TransportStation
{
Name = s.Departure.Station.Name,
Id = s.Departure.Station.Id
} : null,
Departure = s.Departure.Departure,
Arrival = s.Departure.Arrival,
Platform = s.Departure.Platform
} : null,
Arrival = s.Arrival != null ? new TransportCheckpoint
{
Station = s.Arrival.Station != null ? new TransportStation
{
Name = s.Arrival.Station.Name,
Id = s.Arrival.Station.Id
} : null,
Departure = s.Arrival.Departure,
Arrival = s.Arrival.Arrival,
Platform = s.Arrival.Platform
} : null
}).ToList()
};
}
}
// API Response models for OpenData CH Transport
public class TransportApiResponse
{
public List<TransportApiConnection> Connections { get; set; } = new();
}
public class TransportApiConnection
{
public TransportApiCheckpoint? From { get; set; }
public TransportApiCheckpoint? To { get; set; }
public string Duration { get; set; } = string.Empty;
public List<TransportApiSection> Sections { get; set; } = new();
public override string ToString()
{
return $"{From} - {To}";
}
}
public class TransportApiCheckpoint
{
public TransportApiStation? Station { get; set; }
public string Departure { get; set; } = string.Empty;
public string Arrival { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public override string ToString()
{
return $"{Station?.Name}: {Departure} - {Arrival}";
}
}
public class TransportApiStation
{
public string Name { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
}
public class TransportApiSection
{
public TransportApiJourney? Journey { get; set; }
public TransportApiCheckpoint? Departure { get; set; }
public TransportApiCheckpoint? Arrival { get; set; }
}
public class TransportApiJourney
{
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Number { get; set; } = string.Empty;
}
}

View file

@ -1,135 +1,43 @@
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(ITransportApiService transportService, TransportConfiguration config, IMemoryCache cache) : ITransportService
{ {
public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime) public async Task<TransportConnection?> GetConnectionAsync(DateTime shiftStartTime)
{ {
var latestArrivalTime = shiftStartTime.AddMinutes(-config.SafetyBufferMinutes);
var searchDate = shiftStartTime.ToString("yyyy-MM-dd"); var searchDate = shiftStartTime.ToString("yyyy-MM-dd");
// To allow connections that arrive after shift starts, we search from earlier time
// 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");
var url = $"{config.ApiBaseUrl}/connections?from={Uri.EscapeDataString(config.StartStation)}&to={Uri.EscapeDataString(config.EndStation)}&date={searchDate}&time={searchTime}&isArrivalTime=1&limit=5"; // Generate cache key based on request parameters
var response = await httpClient.GetStringAsync(url); var cacheKey = GenerateCacheKey(searchDate, searchTime);
var apiResponse = JsonSerializer.Deserialize<TransportApiResponse>(response, new JsonSerializerOptions
// Try to get from cache first
if (cache.TryGetValue(cacheKey, out TransportConnection? cachedConnection) && cachedConnection != null)
{ {
PropertyNameCaseInsensitive = true return cachedConnection;
});
if (apiResponse?.Connections.Count > 0)
{
var allConnections = apiResponse.Connections.Select(MapToTransportConnection).ToList();
return TransportConnectionCalculator.FindBestConnection(
allConnections,
shiftStartTime,
config.SafetyBufferMinutes,
config.MaxEarlyArrivalMinutes,
config.MaxLateArrivalMinutes);
} }
return null; // Not in cache, call the underlying transport service
} var connection = await transportService.GetConnectionAsync(shiftStartTime);
private TransportConnection MapToTransportConnection(TransportApiConnection? apiConnection) // Cache the connection result if valid
{ if (connection != null)
if (apiConnection == null)
return new TransportConnection();
return new TransportConnection
{ {
DepartureTime = apiConnection.From?.Departure ?? string.Empty, var cacheOptions = new MemoryCacheEntryOptions
ArrivalTime = apiConnection.To?.Arrival ?? string.Empty,
Duration = apiConnection.Duration,
Platform = apiConnection.From?.Platform ?? string.Empty,
Sections = apiConnection.Sections.Select(s => new TransportSection
{ {
Journey = s.Journey != null ? new TransportJourney AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(config.CacheDurationDays)
{ };
Name = s.Journey.Name, cache.Set(cacheKey, connection, cacheOptions);
Category = s.Journey.Category, }
Number = s.Journey.Number
} : null, return connection;
Departure = s.Departure != null ? new TransportCheckpoint
{
Station = s.Departure.Station != null ? new TransportStation
{
Name = s.Departure.Station.Name,
Id = s.Departure.Station.Id
} : null,
Departure = s.Departure.Departure,
Arrival = s.Departure.Arrival,
Platform = s.Departure.Platform
} : null,
Arrival = s.Arrival != null ? new TransportCheckpoint
{
Station = s.Arrival.Station != null ? new TransportStation
{
Name = s.Arrival.Station.Name,
Id = s.Arrival.Station.Id
} : null,
Departure = s.Arrival.Departure,
Arrival = s.Arrival.Arrival,
Platform = s.Arrival.Platform
} : null
}).ToList()
};
} }
}
// API Response models for OpenData CH Transport
public class TransportApiResponse
{
public List<TransportApiConnection> Connections { get; set; } = new();
}
public class TransportApiConnection private string GenerateCacheKey(string searchDate, string searchTime)
{
public TransportApiCheckpoint? From { get; set; }
public TransportApiCheckpoint? To { get; set; }
public string Duration { get; set; } = string.Empty;
public List<TransportApiSection> Sections { get; set; } = new();
public override string ToString()
{ {
return $"{From} - {To}"; return $"transport_{config.StartStation}_{config.EndStation}_{searchDate}_{searchTime}";
} }
} }
public class TransportApiCheckpoint
{
public TransportApiStation? Station { get; set; }
public string Departure { get; set; } = string.Empty;
public string Arrival { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public override string ToString()
{
return $"{Station?.Name}: {Departure} - {Arrival}";
}
}
public class TransportApiStation
{
public string Name { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
}
public class TransportApiSection
{
public TransportApiJourney? Journey { get; set; }
public TransportApiCheckpoint? Departure { get; set; }
public TransportApiCheckpoint? Arrival { get; set; }
}
public class TransportApiJourney
{
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Number { get; set; } = string.Empty;
}
} }

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 CacheDurationDays { get; set; } = 1;
} }
public class TransportConnection public class TransportConnection

View file

@ -0,0 +1,242 @@
using System.Net;
using System.Text.Json;
using Moq;
using Moq.Protected;
using ShiftScheduler.Services;
using ShiftScheduler.Shared;
using Shouldly;
namespace ShiftScheduler.Services.Tests;
public class TransportApiServiceTests
{
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
private readonly HttpClient _httpClient;
private readonly TransportApiService _transportApiService;
public TransportApiServiceTests()
{
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClient = new HttpClient(_httpMessageHandlerMock.Object);
var config = new TransportConfiguration
{
StartStation = "Zurich HB",
EndStation = "Bern",
ApiBaseUrl = "https://transport.opendata.ch/v1",
SafetyBufferMinutes = 30,
MinBreakMinutes = 60,
MaxEarlyArrivalMinutes = 60,
MaxLateArrivalMinutes = 15,
CacheDurationDays = 1
};
_transportApiService = new TransportApiService(_httpClient, config);
}
[Fact]
public async Task GetConnectionAsync_WithValidApiResponse_ShouldReturnMappedConnection()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
var apiResponse = CreateValidApiResponse();
var jsonResponse = JsonSerializer.Serialize(apiResponse);
SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse);
// Act
var result = await _transportApiService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldNotBeNull();
result.DepartureTime.ShouldBe("2023-12-15T06:45:00");
result.ArrivalTime.ShouldBe("2023-12-15T07:30:00");
result.Duration.ShouldBe("00:45:00");
result.Platform.ShouldBe("5");
result.Sections.ShouldNotBeEmpty();
result.Sections.Count.ShouldBe(1);
var section = result.Sections.First();
section.Journey?.Name.ShouldBe("IC 1");
section.Journey?.Category.ShouldBe("IC");
section.Journey?.Number.ShouldBe("1");
}
[Fact]
public async Task GetConnectionAsync_WithEmptyApiResponse_ShouldReturnNull()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
var emptyResponse = new TransportApiResponse { Connections = new List<TransportApiConnection>() };
var jsonResponse = JsonSerializer.Serialize(emptyResponse);
SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse);
// Act
var result = await _transportApiService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldBeNull();
}
[Fact]
public async Task GetConnectionAsync_WithNullApiResponse_ShouldReturnNull()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
SetupHttpMockResponse(HttpStatusCode.OK, "null");
// Act
var result = await _transportApiService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldBeNull();
}
[Fact]
public async Task GetConnectionAsync_WithMultipleConnections_ShouldReturnBestConnection()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
var apiResponse = CreateMultipleConnectionsResponse();
var jsonResponse = JsonSerializer.Serialize(apiResponse);
SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse);
// Act
var result = await _transportApiService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldNotBeNull();
// Now with proper datetime formats, the algorithm should work correctly
// Latest acceptable arrival: 8:00 - 30 min = 7:30
// Valid connections: 07:15 and 07:25 (both arrive before 7:30)
// Algorithm should return the latest valid: 07:25
result.ArrivalTime.ShouldBe("2023-12-15T07:25:00");
}
private void SetupHttpMockResponse(HttpStatusCode statusCode, string content)
{
_httpMessageHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() => new HttpResponseMessage(statusCode)
{
Content = new StringContent(content)
});
}
private static TransportApiResponse CreateValidApiResponse()
{
return new TransportApiResponse
{
Connections = new List<TransportApiConnection>
{
new TransportApiConnection
{
From = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" },
Departure = "2023-12-15T06:45:00",
Platform = "5"
},
To = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Bern", Id = "bern" },
Arrival = "2023-12-15T07:30:00",
Platform = "3"
},
Duration = "00:45:00",
Sections = new List<TransportApiSection>
{
new TransportApiSection
{
Journey = new TransportApiJourney
{
Name = "IC 1",
Category = "IC",
Number = "1"
},
Departure = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" },
Departure = "2023-12-15T06:45:00",
Platform = "5"
},
Arrival = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Bern", Id = "bern" },
Arrival = "2023-12-15T07:30:00",
Platform = "3"
}
}
}
}
}
};
}
private static TransportApiResponse CreateMultipleConnectionsResponse()
{
return new TransportApiResponse
{
Connections = new List<TransportApiConnection>
{
// Early connection - valid
new TransportApiConnection
{
From = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" },
Departure = "2023-12-15T06:30:00",
Platform = "5"
},
To = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Bern", Id = "bern" },
Arrival = "2023-12-15T07:15:00",
Platform = "3"
},
Duration = "00:45:00"
},
// Later valid connection - should be selected as best
new TransportApiConnection
{
From = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" },
Departure = "2023-12-15T06:40:00",
Platform = "4"
},
To = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Bern", Id = "bern" },
Arrival = "2023-12-15T07:25:00",
Platform = "2"
},
Duration = "00:45:00"
},
// Too late connection - invalid (arrives after 07:30 which is shift start - 30 min buffer)
new TransportApiConnection
{
From = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" },
Departure = "2023-12-15T06:50:00",
Platform = "6"
},
To = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Bern", Id = "bern" },
Arrival = "2023-12-15T07:35:00",
Platform = "1"
},
Duration = "00:45:00"
}
}
};
}
}

View file

@ -1,7 +1,5 @@
using System.Net; using Microsoft.Extensions.Caching.Memory;
using System.Text.Json;
using Moq; using Moq;
using Moq.Protected;
using ShiftScheduler.Services; using ShiftScheduler.Services;
using ShiftScheduler.Shared; using ShiftScheduler.Shared;
using Shouldly; using Shouldly;
@ -10,16 +8,17 @@ namespace ShiftScheduler.Services.Tests;
public class TransportServiceTests public class TransportServiceTests
{ {
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock; private readonly Mock<ITransportApiService> _transportServiceMock;
private readonly HttpClient _httpClient; private readonly IMemoryCache _memoryCache;
private readonly TransportService _transportService; private readonly TransportService _transportService;
private readonly TransportConfiguration _config;
public TransportServiceTests() public TransportServiceTests()
{ {
_httpMessageHandlerMock = new Mock<HttpMessageHandler>(); _transportServiceMock = new Mock<ITransportApiService>();
_httpClient = new HttpClient(_httpMessageHandlerMock.Object); _memoryCache = new MemoryCache(new MemoryCacheOptions());
var config = new TransportConfiguration _config = new TransportConfiguration
{ {
StartStation = "Zurich HB", StartStation = "Zurich HB",
EndStation = "Bern", EndStation = "Bern",
@ -27,217 +26,99 @@ public class TransportServiceTests
SafetyBufferMinutes = 30, SafetyBufferMinutes = 30,
MinBreakMinutes = 60, MinBreakMinutes = 60,
MaxEarlyArrivalMinutes = 60, MaxEarlyArrivalMinutes = 60,
MaxLateArrivalMinutes = 15 MaxLateArrivalMinutes = 15,
CacheDurationDays = 1
}; };
_transportService = new TransportService(_httpClient, config); _transportService = new TransportService(_transportServiceMock.Object, _config, _memoryCache);
} }
[Fact] [Fact]
public async Task GetConnectionAsync_WithValidApiResponse_ShouldReturnMappedConnection() public async Task GetConnectionAsync_WithValidConnection_ShouldCacheResult()
{ {
// Arrange // Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
var apiResponse = CreateValidApiResponse(); var connection = new TransportConnection
var jsonResponse = JsonSerializer.Serialize(apiResponse);
SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse);
// Act
var result = await _transportService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldNotBeNull();
result.DepartureTime.ShouldBe("2023-12-15T06:45:00");
result.ArrivalTime.ShouldBe("2023-12-15T07:30:00");
result.Duration.ShouldBe("00:45:00");
result.Platform.ShouldBe("5");
result.Sections.ShouldNotBeEmpty();
result.Sections.Count.ShouldBe(1);
var section = result.Sections.First();
section.Journey?.Name.ShouldBe("IC 1");
section.Journey?.Category.ShouldBe("IC");
section.Journey?.Number.ShouldBe("1");
}
[Fact]
public async Task GetConnectionAsync_WithEmptyApiResponse_ShouldReturnNull()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
var emptyResponse = new TransportApiResponse { Connections = new List<TransportApiConnection>() };
var jsonResponse = JsonSerializer.Serialize(emptyResponse);
SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse);
// Act
var result = await _transportService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldBeNull();
}
[Fact]
public async Task GetConnectionAsync_WithNullApiResponse_ShouldReturnNull()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
SetupHttpMockResponse(HttpStatusCode.OK, "null");
// Act
var result = await _transportService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldBeNull();
}
[Fact]
public async Task GetConnectionAsync_WithMultipleConnections_ShouldReturnBestConnection()
{
// Arrange
var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
var apiResponse = CreateMultipleConnectionsResponse();
var jsonResponse = JsonSerializer.Serialize(apiResponse);
SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse);
// Act
var result = await _transportService.GetConnectionAsync(shiftStartTime);
// Assert
result.ShouldNotBeNull();
// Now with proper datetime formats, the algorithm should work correctly
// Latest acceptable arrival: 8:00 - 30 min = 7:30
// Valid connections: 07:15 and 07:25 (both arrive before 7:30)
// Algorithm should return the latest valid: 07:25
result.ArrivalTime.ShouldBe("2023-12-15T07:25:00");
}
private void SetupHttpMockResponse(HttpStatusCode statusCode, string content)
{
var response = new HttpResponseMessage(statusCode)
{ {
Content = new StringContent(content) 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);
_httpMessageHandlerMock.Protected() // Act - First call should hit the transport service
.Setup<Task<HttpResponseMessage>>( var result1 = await _transportService.GetConnectionAsync(shiftStartTime);
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(), // Act - Second call should use cache
ItExpr.IsAny<CancellationToken>()) var result2 = await _transportService.GetConnectionAsync(shiftStartTime);
.ReturnsAsync(response);
// 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);
} }
private static TransportApiResponse CreateValidApiResponse() [Fact]
public async Task GetConnectionAsync_WithNullConnection_ShouldNotCache()
{ {
return new TransportApiResponse // Arrange
{ var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0);
Connections = new List<TransportApiConnection>
{ _transportServiceMock
new TransportApiConnection .Setup(x => x.GetConnectionAsync(shiftStartTime))
{ .ReturnsAsync((TransportConnection?)null);
From = new TransportApiCheckpoint
{ // Act - First call
Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" }, var result1 = await _transportService.GetConnectionAsync(shiftStartTime);
Departure = "2023-12-15T06:45:00",
Platform = "5" // Act - Second call should call transport service again since null wasn't cached
}, var result2 = await _transportService.GetConnectionAsync(shiftStartTime);
To = new TransportApiCheckpoint
{ // Assert
Station = new TransportApiStation { Name = "Bern", Id = "bern" }, result1.ShouldBeNull();
Arrival = "2023-12-15T07:30:00", result2.ShouldBeNull();
Platform = "3"
}, // Verify that transport service was called twice (no caching for null)
Duration = "00:45:00", _transportServiceMock.Verify(x => x.GetConnectionAsync(shiftStartTime), Times.Exactly(2));
Sections = new List<TransportApiSection>
{
new TransportApiSection
{
Journey = new TransportApiJourney
{
Name = "IC 1",
Category = "IC",
Number = "1"
},
Departure = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" },
Departure = "2023-12-15T06:45:00",
Platform = "5"
},
Arrival = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Bern", Id = "bern" },
Arrival = "2023-12-15T07:30:00",
Platform = "3"
}
}
}
}
}
};
} }
private static TransportApiResponse CreateMultipleConnectionsResponse() [Fact]
public async Task GetConnectionAsync_WithDifferentDates_ShouldCreateSeparateCacheEntries()
{ {
return new TransportApiResponse // Arrange
{ var shiftStartTime1 = new DateTime(2023, 12, 15, 8, 0, 0);
Connections = new List<TransportApiConnection> var shiftStartTime2 = new DateTime(2023, 12, 16, 8, 0, 0);
{
// Early connection - valid var connection1 = new TransportConnection { ArrivalTime = "2023-12-15T07:30:00" };
new TransportApiConnection var connection2 = new TransportConnection { ArrivalTime = "2023-12-16T07:30:00" };
{
From = new TransportApiCheckpoint _transportServiceMock
{ .Setup(x => x.GetConnectionAsync(shiftStartTime1))
Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" }, .ReturnsAsync(connection1);
Departure = "2023-12-15T06:30:00",
Platform = "5" _transportServiceMock
}, .Setup(x => x.GetConnectionAsync(shiftStartTime2))
To = new TransportApiCheckpoint .ReturnsAsync(connection2);
{
Station = new TransportApiStation { Name = "Bern", Id = "bern" }, // Act - Different dates should result in different cache keys
Arrival = "2023-12-15T07:15:00", var result1 = await _transportService.GetConnectionAsync(shiftStartTime1);
Platform = "3" var result2 = await _transportService.GetConnectionAsync(shiftStartTime2);
},
Duration = "00:45:00" // Assert
}, result1.ShouldNotBeNull();
// Later valid connection - should be selected as best result2.ShouldNotBeNull();
new TransportApiConnection result1.ArrivalTime.ShouldBe("2023-12-15T07:30:00");
{ result2.ArrivalTime.ShouldBe("2023-12-16T07:30:00");
From = new TransportApiCheckpoint
{ // Verify that transport service was called twice (different cache keys)
Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" }, _transportServiceMock.Verify(x => x.GetConnectionAsync(It.IsAny<DateTime>()), Times.Exactly(2));
Departure = "2023-12-15T06:40:00",
Platform = "4"
},
To = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Bern", Id = "bern" },
Arrival = "2023-12-15T07:25:00",
Platform = "2"
},
Duration = "00:45:00"
},
// Too late connection - invalid (arrives after 07:30 which is shift start - 30 min buffer)
new TransportApiConnection
{
From = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Zurich HB", Id = "zurich" },
Departure = "2023-12-15T06:50:00",
Platform = "6"
},
To = new TransportApiCheckpoint
{
Station = new TransportApiStation { Name = "Bern", Id = "bern" },
Arrival = "2023-12-15T07:35:00",
Platform = "1"
},
Duration = "00:45:00"
}
}
};
} }
} }