diff --git a/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs index f9de8d6..60195f4 100644 --- a/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs +++ b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs @@ -638,4 +638,183 @@ public async Task FindTrainConnectionsAsync_WhenNoConnectionsFound_ReturnsNoConn Assert.Contains("- Trains may require a transfer", result); Assert.Contains("- Try a different time or date", result); } + + [Fact] + public async Task GetStationBoardAsync_WithProvidedDate_UsesDateDirectlyWithoutTimezoneConversion() + { + // Arrange + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(_testXmlResponse) + }); + + var httpClient = new HttpClient(mockHandler.Object) + { + BaseAddress = new Uri(_config.BaseUrl) + }; + var service = new TimeTableService(httpClient, _mockOptions.Object); + var evaNo = "8000105"; + + // Provide a specific German time: 2025-11-06 14:30 + var germanTime = new DateTime(2025, 11, 6, 14, 30, 0); + + // Act + var result = await service.GetStationBoardAsync(evaNo, germanTime); + + // Assert + Assert.Equal(_testXmlResponse, result); + + // Verify the request path contains the exact date and hour from the provided date + // without any timezone conversion + mockHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => + req.RequestUri!.PathAndQuery.Contains("plan/8000105/251106/14")), + ItExpr.IsAny()); + } + + [Fact] + public async Task GetStationBoardAsync_WithoutDate_UsesGermanTimezone() + { + // Arrange + var mockHandler = new Mock(); + string? capturedPath = null; + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage req, CancellationToken ct) => + { + capturedPath = req.RequestUri?.PathAndQuery; + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(_testXmlResponse) + }; + }); + + var httpClient = new HttpClient(mockHandler.Object) + { + BaseAddress = new Uri(_config.BaseUrl) + }; + var service = new TimeTableService(httpClient, _mockOptions.Object); + var evaNo = "8000105"; + + // Calculate expected German time + var berlinTz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); + var expectedGermanTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, berlinTz); + var expectedDate = expectedGermanTime.ToString("yyMMdd"); + var expectedHour = expectedGermanTime.ToString("HH"); + + // Act + var result = await service.GetStationBoardAsync(evaNo); + + // Assert + Assert.Equal(_testXmlResponse, result); + Assert.NotNull(capturedPath); + Assert.Contains($"plan/{evaNo}/{expectedDate}/{expectedHour}", capturedPath); + } + + [Fact] + public async Task GetStationBoardAsync_WhenTimezoneNotAvailable_UsesFallback() + { + // This test verifies the fallback behavior when Europe/Berlin timezone cannot be found + // Note: This is difficult to test directly since we can't easily mock TimeZoneInfo.FindSystemTimeZoneById + // In actual deployment, the Europe/Berlin timezone should always be available on the system + // The fallback to DateTime.Now is a defensive measure for edge cases + + // Arrange + var mockHandler = new Mock(); + string? capturedPath = null; + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage req, CancellationToken ct) => + { + capturedPath = req.RequestUri?.PathAndQuery; + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(_testXmlResponse) + }; + }); + + var httpClient = new HttpClient(mockHandler.Object) + { + BaseAddress = new Uri(_config.BaseUrl) + }; + var service = new TimeTableService(httpClient, _mockOptions.Object); + var evaNo = "8000105"; + + // Act + var result = await service.GetStationBoardAsync(evaNo); + + // Assert + // The method should succeed and return a result regardless of timezone availability + Assert.Equal(_testXmlResponse, result); + Assert.NotNull(capturedPath); + // Verify that a valid path was created with date and hour components + Assert.Matches(@"plan/\d+/\d{6}/\d{2}", capturedPath); + } + + [Fact] + public async Task GetStationBoardAsync_WithMultipleCalls_UsesConsistentTimezoneLogic() + { + // Arrange + var mockHandler = new Mock(); + var capturedPaths = new List(); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage req, CancellationToken ct) => + { + if (req.RequestUri?.PathAndQuery != null) + capturedPaths.Add(req.RequestUri.PathAndQuery); + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(_testXmlResponse) + }; + }); + + var httpClient = new HttpClient(mockHandler.Object) + { + BaseAddress = new Uri(_config.BaseUrl) + }; + var service = new TimeTableService(httpClient, _mockOptions.Object); + var evaNo = "8000105"; + var specificDate = new DateTime(2025, 12, 25, 10, 15, 0); + + // Act + // First call without date (should use German timezone) + await service.GetStationBoardAsync(evaNo); + // Second call with specific date (should use provided date directly) + await service.GetStationBoardAsync(evaNo, specificDate); + // Third call without date again (should use German timezone) + await service.GetStationBoardAsync(evaNo); + + // Assert + Assert.Equal(3, capturedPaths.Count); + + // Second call should use the exact provided date + Assert.Contains("plan/8000105/251225/10", capturedPaths[1]); + + // First and third calls should use current German time (so they should be similar) + // Both should contain valid date patterns + Assert.Matches(@"plan/\d+/\d{6}/\d{2}", capturedPaths[0]); + Assert.Matches(@"plan/\d+/\d{6}/\d{2}", capturedPaths[2]); + } } diff --git a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs index 4de5189..55be155 100644 --- a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs +++ b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs @@ -48,8 +48,32 @@ public async Task GetStationBoardAsync( CancellationToken cancellationToken = default) { // Format: yyMMddHHmm (e.g., 2511051830 for 2025-11-05 18:30) - var dateParam = date?.ToString("yyMMdd") ?? DateTime.UtcNow.ToString("yyMMdd"); - var hourParam = date?.ToString("HH") ?? DateTime.UtcNow.ToString("HH"); + // If date is provided by user, use it directly (user is expected to provide German time) + // If not provided, get current time in German timezone + DateTime effectiveTime; + if (date.HasValue) + { + effectiveTime = date.Value; + } + else + { + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); + effectiveTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz); + } + catch (TimeZoneNotFoundException) + { + effectiveTime = DateTime.Now; // fallback to local time if timezone not found + } + catch (InvalidTimeZoneException) + { + effectiveTime = DateTime.Now; // fallback to local time if timezone invalid + } + } + + var dateParam = effectiveTime.ToString("yyMMdd"); + var hourParam = effectiveTime.ToString("HH"); using var request = new HttpRequestMessage(HttpMethod.Get, $"plan/{evaNo}/{dateParam}/{hourParam}"); request.Headers.Add("DB-Client-Id", _config.ClientId); diff --git a/AbeckDev.DbTimetable.Mcp/Tools.cs b/AbeckDev.DbTimetable.Mcp/Tools.cs index 3ecd04c..24eb10d 100644 --- a/AbeckDev.DbTimetable.Mcp/Tools.cs +++ b/AbeckDev.DbTimetable.Mcp/Tools.cs @@ -46,7 +46,7 @@ public async Task GetFullStationChanges( [Description("Get station board (departures and arrivals) for a specific station in hourly slices. Returns XML data with train schedules.")] public async Task GetStationBoard( [Description("EVA station number (e.g., 8000105 for Frankfurt Hauptbahnhof)")] string evaNo, - [Description("Date and time in format 'yyyy-MM-dd HH:mm' (UTC). Leave empty for current time.")] string? dateTime = null) + [Description("Date and time in format 'yyyy-MM-dd HH:mm' (German Time). Leave empty for current time.")] string? dateTime = null) { try { @@ -119,7 +119,7 @@ public async Task GetStationBoard( public async Task FindTrainConnections( [Description("Starting station name or EVA number (e.g., 'Frankfurt Hbf' or '8000105')")] string stationA, [Description("Destination station name or EVA number (e.g., 'Berlin Hbf' or '8011160')")] string stationB, - [Description("Date and time in format 'yyyy-MM-dd HH:mm' (UTC). Leave empty for current time.")] string? dateTime = null) + [Description("Date and time in format 'yyyy-MM-dd HH:mm' (German Time). Leave empty for current time.")] string? dateTime = null) { try {