Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpMessageHandler>();
mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<HttpRequestMessage>(req =>
req.RequestUri!.PathAndQuery.Contains("plan/8000105/251106/14")),
ItExpr.IsAny<CancellationToken>());
}

[Fact]
public async Task GetStationBoardAsync_WithoutDate_UsesGermanTimezone()
{
// Arrange
var mockHandler = new Mock<HttpMessageHandler>();
string? capturedPath = null;
mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<HttpMessageHandler>();
string? capturedPath = null;
mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<HttpMessageHandler>();
var capturedPaths = new List<string>();
mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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]);
}
}
28 changes: 26 additions & 2 deletions AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,32 @@ public async Task<string> 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);
Expand Down
4 changes: 2 additions & 2 deletions AbeckDev.DbTimetable.Mcp/Tools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public async Task<string> GetFullStationChanges(
[Description("Get station board (departures and arrivals) for a specific station in hourly slices. Returns XML data with train schedules.")]
public async Task<string> 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
{
Expand Down Expand Up @@ -119,7 +119,7 @@ public async Task<string> GetStationBoard(
public async Task<string> 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
{
Expand Down