From d344f7e67032c9905da6408f75c6666796a06813 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 Aug 2025 16:24:10 +0000 Subject: [PATCH] Add comprehensive TransportService unit tests with xUnit and Shouldly Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com> --- .../ShiftScheduler.Services.Tests.csproj | 28 ++ .../TransportServiceTests.cs | 412 ++++++++++++++++++ ShiftScheduler.sln | 41 +- 3 files changed, 468 insertions(+), 13 deletions(-) create mode 100644 ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj create mode 100644 ShiftScheduler.Services.Tests/TransportServiceTests.cs diff --git a/ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj b/ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj new file mode 100644 index 0000000..feb6af5 --- /dev/null +++ b/ShiftScheduler.Services.Tests/ShiftScheduler.Services.Tests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/ShiftScheduler.Services.Tests/TransportServiceTests.cs b/ShiftScheduler.Services.Tests/TransportServiceTests.cs new file mode 100644 index 0000000..f362acc --- /dev/null +++ b/ShiftScheduler.Services.Tests/TransportServiceTests.cs @@ -0,0 +1,412 @@ +using System.Net; +using System.Text.Json; +using Moq; +using Moq.Protected; +using ShiftScheduler.Services; +using ShiftScheduler.Shared.Models; +using Shouldly; + +namespace ShiftScheduler.Services.Tests; + +public class TransportServiceTests +{ + private readonly Mock _httpMessageHandlerMock; + private readonly HttpClient _httpClient; + private readonly TransportConfiguration _config; + private readonly TransportService _transportService; + + public TransportServiceTests() + { + _httpMessageHandlerMock = new Mock(); + _httpClient = new HttpClient(_httpMessageHandlerMock.Object); + + _config = new TransportConfiguration + { + StartStation = "Zurich HB", + EndStation = "Bern", + ApiBaseUrl = "https://transport.opendata.ch/v1", + SafetyBufferMinutes = 30, + MinBreakMinutes = 60 + }; + + _transportService = new TransportService(_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 _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_WithCustomEndStation_ShouldUseCustomStation() + { + // Arrange + var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + var customEndStation = "Basel"; + var apiResponse = CreateValidApiResponse(); + var jsonResponse = JsonSerializer.Serialize(apiResponse); + + SetupHttpMockResponse(HttpStatusCode.OK, jsonResponse); + + // Act + await _transportService.GetConnectionAsync(shiftStartTime, customEndStation); + + // Assert + _httpMessageHandlerMock.Protected() + .Verify("SendAsync", Times.Once(), + ItExpr.Is(req => + req.RequestUri!.ToString().Contains(Uri.EscapeDataString(customEndStation))), + ItExpr.IsAny()); + } + + [Fact] + public async Task GetConnectionAsync_WithHttpException_ShouldReturnMockConnection() + { + // Arrange + var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + + SetupHttpMockResponse(HttpStatusCode.InternalServerError, "Server Error"); + + // Act + var result = await _transportService.GetConnectionAsync(shiftStartTime); + + // Assert + result.ShouldNotBeNull(); + result.DepartureTime.ShouldNotBeNullOrEmpty(); + result.ArrivalTime.ShouldNotBeNullOrEmpty(); + result.Duration.ShouldBe("00:45:00"); + result.Platform.ShouldBe("3"); + } + + [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"); + } + + [Fact] + public void FormatConnectionSummary_WithValidConnection_ShouldReturnFormattedString() + { + // Arrange + var connection = new TransportConnection + { + DepartureTime = "06:45", + ArrivalTime = "07:30", + Sections = new List + { + new TransportSection + { + Journey = new TransportJourney + { + Category = "IC", + Number = "1" + } + } + } + }; + + // Act + var result = _transportService.FormatConnectionSummary(connection); + + // Assert + result.ShouldBe("IC 1: 06:45 → 07:30"); + } + + [Fact] + public void FormatConnectionSummary_WithNullConnection_ShouldReturnNoTransportInfo() + { + // Act + var result = _transportService.FormatConnectionSummary(null); + + // Assert + result.ShouldBe("No transport info"); + } + + [Fact] + public void FormatConnectionSummary_WithEmptyDepartureTime_ShouldReturnNoTransportInfo() + { + // Arrange + var connection = new TransportConnection + { + DepartureTime = "", + ArrivalTime = "07:30" + }; + + // Act + var result = _transportService.FormatConnectionSummary(connection); + + // Assert + result.ShouldBe("No transport info"); + } + + [Fact] + public void FormatConnectionSummary_WithNoJourney_ShouldUseDefaultTrainLabel() + { + // Arrange + var connection = new TransportConnection + { + DepartureTime = "06:45", + ArrivalTime = "07:30", + Sections = new List() + }; + + // Act + var result = _transportService.FormatConnectionSummary(connection); + + // Assert + result.ShouldBe("Train: 06:45 → 07:30"); + } + + [Fact] + public void FormatConnectionSummary_WithDateTimeParsing_ShouldFormatCorrectly() + { + // Arrange + var connection = new TransportConnection + { + DepartureTime = "2023-12-15T06:45:00", + ArrivalTime = "2023-12-15T07:30:00", + Sections = new List + { + new TransportSection + { + Journey = new TransportJourney + { + Category = "S", + Number = "3" + } + } + } + }; + + // Act + var result = _transportService.FormatConnectionSummary(connection); + + // Assert + result.ShouldBe("S 3: 06:45 → 07:30"); + } + + [Theory] + [InlineData(30, "07:25")] // Should arrive 5 minutes before latest acceptable time + [InlineData(60, "06:55")] // Should arrive 5 minutes before latest acceptable time with 60 min buffer + [InlineData(15, "07:40")] // Should arrive 5 minutes before latest acceptable time with 15 min buffer + public async Task GetConnectionAsync_WithDifferentSafetyBuffers_ShouldCreateCorrectMockConnection( + int safetyBufferMinutes, string expectedArrivalTime) + { + // Arrange + var config = new TransportConfiguration + { + StartStation = "Test Start", + EndStation = "Test End", + ApiBaseUrl = "https://test.api", + SafetyBufferMinutes = safetyBufferMinutes + }; + var service = new TransportService(_httpClient, config); + var shiftStartTime = new DateTime(2023, 12, 15, 8, 0, 0); + + SetupHttpMockResponse(HttpStatusCode.InternalServerError, "Error"); + + // Act + var result = await service.GetConnectionAsync(shiftStartTime); + + // Assert + result.ShouldNotBeNull(); + result.ArrivalTime.ShouldBe(expectedArrivalTime); + } + + private void SetupHttpMockResponse(HttpStatusCode statusCode, string content) + { + var response = new HttpResponseMessage(statusCode) + { + Content = new StringContent(content) + }; + + _httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + } + + 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.sln b/ShiftScheduler.sln index afb44fb..b581e1e 100644 --- a/ShiftScheduler.sln +++ b/ShiftScheduler.sln @@ -1,4 +1,5 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 + +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.0.0 MinimumVisualStudioVersion = 16.0.0.0 @@ -10,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ShiftScheduler.Shared", "Sh EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShiftScheduler.Services", "Services\ShiftScheduler.Services.csproj", "{2026B7BD-15D0-462D-A52B-7483420BF367}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShiftScheduler.Services.Tests", "ShiftScheduler.Services.Tests\ShiftScheduler.Services.Tests.csproj", "{1AE51E5B-B802-4C06-A15A-AE4F833FD82D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -20,18 +23,6 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|x64.ActiveCfg = Debug|Any CPU - {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|x64.Build.0 = Debug|Any CPU - {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|x86.ActiveCfg = Debug|Any CPU - {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|x86.Build.0 = Debug|Any CPU - {D77D0134-43AC-47DD-8385-11260622CE51}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D77D0134-43AC-47DD-8385-11260622CE51}.Release|Any CPU.Build.0 = Release|Any CPU - {D77D0134-43AC-47DD-8385-11260622CE51}.Release|x64.ActiveCfg = Release|Any CPU - {D77D0134-43AC-47DD-8385-11260622CE51}.Release|x64.Build.0 = Release|Any CPU - {D77D0134-43AC-47DD-8385-11260622CE51}.Release|x86.ActiveCfg = Release|Any CPU - {D77D0134-43AC-47DD-8385-11260622CE51}.Release|x86.Build.0 = Release|Any CPU {79DA0FC7-E0D9-46AD-838F-A755602D2336}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {79DA0FC7-E0D9-46AD-838F-A755602D2336}.Debug|Any CPU.Build.0 = Debug|Any CPU {79DA0FC7-E0D9-46AD-838F-A755602D2336}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -44,6 +35,18 @@ Global {79DA0FC7-E0D9-46AD-838F-A755602D2336}.Release|x64.Build.0 = Release|Any CPU {79DA0FC7-E0D9-46AD-838F-A755602D2336}.Release|x86.ActiveCfg = Release|Any CPU {79DA0FC7-E0D9-46AD-838F-A755602D2336}.Release|x86.Build.0 = Release|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|x64.ActiveCfg = Debug|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|x64.Build.0 = Debug|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|x86.ActiveCfg = Debug|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Debug|x86.Build.0 = Debug|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Release|Any CPU.Build.0 = Release|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Release|x64.ActiveCfg = Release|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Release|x64.Build.0 = Release|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Release|x86.ActiveCfg = Release|Any CPU + {D77D0134-43AC-47DD-8385-11260622CE51}.Release|x86.Build.0 = Release|Any CPU {23695835-5461-423A-93C1-5B6B3C6718D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {23695835-5461-423A-93C1-5B6B3C6718D5}.Debug|Any CPU.Build.0 = Debug|Any CPU {23695835-5461-423A-93C1-5B6B3C6718D5}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -68,6 +71,18 @@ Global {2026B7BD-15D0-462D-A52B-7483420BF367}.Release|x64.Build.0 = Release|Any CPU {2026B7BD-15D0-462D-A52B-7483420BF367}.Release|x86.ActiveCfg = Release|Any CPU {2026B7BD-15D0-462D-A52B-7483420BF367}.Release|x86.Build.0 = Release|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Debug|x64.ActiveCfg = Debug|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Debug|x64.Build.0 = Debug|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Debug|x86.ActiveCfg = Debug|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Debug|x86.Build.0 = Debug|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Release|Any CPU.Build.0 = Release|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Release|x64.ActiveCfg = Release|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Release|x64.Build.0 = Release|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Release|x86.ActiveCfg = Release|Any CPU + {1AE51E5B-B802-4C06-A15A-AE4F833FD82D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE