From 0326cc3adf1a38046c35f47a62edd5888a5a09b7 Mon Sep 17 00:00:00 2001 From: Claudio Schaad Date: Mon, 25 Aug 2025 21:13:58 +0200 Subject: [PATCH] Code cleanup --- Server/Program.cs | 6 +- Services/CachedTransportService.cs | 43 --- Services/TransportApiService.cs | 133 ++++++++ Services/TransportService.cs | 134 ++------ .../CachedTransportServiceTests.cs | 124 -------- .../TransportApiServiceTests.cs | 242 +++++++++++++++ .../TransportServiceTests.cs | 290 ++++++------------ 7 files changed, 485 insertions(+), 487 deletions(-) delete mode 100644 Services/CachedTransportService.cs create mode 100644 Services/TransportApiService.cs delete mode 100644 ShiftScheduler.Services.Tests/CachedTransportServiceTests.cs create mode 100644 ShiftScheduler.Services.Tests/TransportApiServiceTests.cs diff --git a/Server/Program.cs b/Server/Program.cs index 89a6aa1..7e52165 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -11,12 +11,12 @@ var transportConfig = builder.Configuration.GetSection("Transport").Get(); +builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddControllersWithViews(); builder.Services.AddRazorPages(); diff --git a/Services/CachedTransportService.cs b/Services/CachedTransportService.cs deleted file mode 100644 index 36e7d88..0000000 --- a/Services/CachedTransportService.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using ShiftScheduler.Shared; - -namespace ShiftScheduler.Services -{ - public class CachedTransportService(ITransportApiService transportService, TransportConfiguration config, IMemoryCache cache) : ITransportService - { - public async Task 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}"; - } - } -} \ No newline at end of file diff --git a/Services/TransportApiService.cs b/Services/TransportApiService.cs new file mode 100644 index 0000000..6b511f2 --- /dev/null +++ b/Services/TransportApiService.cs @@ -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 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(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 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 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; + } +} \ No newline at end of file diff --git a/Services/TransportService.cs b/Services/TransportService.cs index 4047020..94647ee 100644 --- a/Services/TransportService.cs +++ b/Services/TransportService.cs @@ -1,135 +1,43 @@ -using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; using ShiftScheduler.Shared; namespace ShiftScheduler.Services { - public class TransportService(HttpClient httpClient, TransportConfiguration config) : ITransportApiService + public class TransportService(ITransportApiService transportService, TransportConfiguration config, IMemoryCache cache) : ITransportService { public async Task GetConnectionAsync(DateTime shiftStartTime) { - var latestArrivalTime = shiftStartTime.AddMinutes(-config.SafetyBufferMinutes); 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 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(response, new JsonSerializerOptions + // 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) { - 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 cachedConnection; } - return null; - } + // Not in cache, call the underlying transport service + var connection = await transportService.GetConnectionAsync(shiftStartTime); - private TransportConnection MapToTransportConnection(TransportApiConnection? apiConnection) - { - if (apiConnection == null) - return new TransportConnection(); - - return new TransportConnection + // Cache the connection result if valid + if (connection != null) { - 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 + var cacheOptions = new MemoryCacheEntryOptions { - 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() - }; + AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(config.CacheDurationDays) + }; + cache.Set(cacheKey, connection, cacheOptions); + } + + return connection; } - } - - // API Response models for OpenData CH Transport - public class TransportApiResponse - { - public List 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 Sections { get; set; } = new(); - - public override string ToString() + private string GenerateCacheKey(string searchDate, string searchTime) { - 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; - } } \ No newline at end of file diff --git a/ShiftScheduler.Services.Tests/CachedTransportServiceTests.cs b/ShiftScheduler.Services.Tests/CachedTransportServiceTests.cs deleted file mode 100644 index aee4fd2..0000000 --- a/ShiftScheduler.Services.Tests/CachedTransportServiceTests.cs +++ /dev/null @@ -1,124 +0,0 @@ -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 _transportServiceMock; - private readonly IMemoryCache _memoryCache; - private readonly CachedTransportService _cachedTransportService; - private readonly TransportConfiguration _config; - - public CachedTransportServiceTests() - { - _transportServiceMock = new Mock(); - _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()), Times.Exactly(2)); - } -} \ No newline at end of file diff --git a/ShiftScheduler.Services.Tests/TransportApiServiceTests.cs b/ShiftScheduler.Services.Tests/TransportApiServiceTests.cs new file mode 100644 index 0000000..8065a29 --- /dev/null +++ b/ShiftScheduler.Services.Tests/TransportApiServiceTests.cs @@ -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 _httpMessageHandlerMock; + private readonly HttpClient _httpClient; + private readonly TransportApiService _transportApiService; + + public TransportApiServiceTests() + { + _httpMessageHandlerMock = new Mock(); + _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() }; + 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>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => new HttpResponseMessage(statusCode) + { + Content = new StringContent(content) + }); + } + + private static TransportApiResponse CreateValidApiResponse() + { + return new TransportApiResponse + { + Connections = new List + { + 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 + { + 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 + { + // 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" + } + } + }; + } +} \ No newline at end of file diff --git a/ShiftScheduler.Services.Tests/TransportServiceTests.cs b/ShiftScheduler.Services.Tests/TransportServiceTests.cs index 23ff77a..fe0f265 100644 --- a/ShiftScheduler.Services.Tests/TransportServiceTests.cs +++ b/ShiftScheduler.Services.Tests/TransportServiceTests.cs @@ -1,7 +1,5 @@ -using System.Net; -using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; using Moq; -using Moq.Protected; using ShiftScheduler.Services; using ShiftScheduler.Shared; using Shouldly; @@ -10,16 +8,17 @@ namespace ShiftScheduler.Services.Tests; public class TransportServiceTests { - private readonly Mock _httpMessageHandlerMock; - private readonly HttpClient _httpClient; + private readonly Mock _transportServiceMock; + private readonly IMemoryCache _memoryCache; private readonly TransportService _transportService; + private readonly TransportConfiguration _config; public TransportServiceTests() { - _httpMessageHandlerMock = new Mock(); - _httpClient = new HttpClient(_httpMessageHandlerMock.Object); + _transportServiceMock = new Mock(); + _memoryCache = new MemoryCache(new MemoryCacheOptions()); - var config = new TransportConfiguration + _config = new TransportConfiguration { StartStation = "Zurich HB", EndStation = "Bern", @@ -31,212 +30,95 @@ public class TransportServiceTests CacheDurationDays = 1 }; - _transportService = new TransportService(_httpClient, config); + _transportService = new TransportService(_transportServiceMock.Object, _config, _memoryCache); } [Fact] - public async Task GetConnectionAsync_WithValidApiResponse_ShouldReturnMappedConnection() + public async Task GetConnectionAsync_WithValidConnection_ShouldCacheResult() { // 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 _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() }; - 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) - { - _httpMessageHandlerMock.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => new HttpResponseMessage(statusCode) - { - Content = new StringContent(content) - }); - } - - private static TransportApiResponse CreateValidApiResponse() - { - return new TransportApiResponse + var connection = new TransportConnection { - Connections = new List - { - 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 - { - 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" - } - } - } - } - } + 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 _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 transport service was called only once + _transportServiceMock.Verify(x => x.GetConnectionAsync(shiftStartTime), Times.Once); } - private static TransportApiResponse CreateMultipleConnectionsResponse() + [Fact] + public async Task GetConnectionAsync_WithNullConnection_ShouldNotCache() { - return new TransportApiResponse - { - Connections = new List - { - // 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" - } - } - }; + // 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 _transportService.GetConnectionAsync(shiftStartTime); + + // Act - Second call should call transport service again since null wasn't cached + var result2 = await _transportService.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 _transportService.GetConnectionAsync(shiftStartTime1); + var result2 = await _transportService.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()), Times.Exactly(2)); } } \ No newline at end of file