diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml
new file mode 100644
index 0000000..58566d4
--- /dev/null
+++ b/.github/workflows/dotnet-ci.yml
@@ -0,0 +1,73 @@
+name: .NET CI with Code Coverage
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ checks: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 9.0.x
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./TestResults
+
+ - name: Upload coverage reports to Codecov
+ uses: codecov/codecov-action@v5
+ with:
+ directory: ./TestResults
+ fail_ci_if_error: false
+ verbose: true
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+
+ - name: Generate Coverage Report
+ uses: danielpalme/ReportGenerator-GitHub-Action@5.4.2
+ with:
+ reports: 'TestResults/**/coverage.cobertura.xml'
+ targetdir: 'TestResults/CoverageReport'
+ reporttypes: 'Html;TextSummary;Cobertura;Badges'
+
+ - name: Display Coverage Summary
+ run: |
+ echo "## Code Coverage Summary" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ cat TestResults/CoverageReport/Summary.txt >> $GITHUB_STEP_SUMMARY
+
+ - name: Upload Coverage Report as Artifact
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: coverage-report
+ path: TestResults/CoverageReport
+ retention-days: 30
+
+ - name: Check Coverage Threshold
+ run: |
+ COVERAGE=$(grep -oP 'Line coverage: \K[0-9.]+' TestResults/CoverageReport/Summary.txt)
+ echo "Current coverage: $COVERAGE%"
+ THRESHOLD=70.0
+ if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
+ echo "โ Coverage ($COVERAGE%) is below threshold ($THRESHOLD%)"
+ exit 1
+ else
+ echo "โ
Coverage ($COVERAGE%) meets threshold ($THRESHOLD%)"
+ fi
diff --git a/.gitignore b/.gitignore
index d66631f..2605e47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,6 +55,9 @@ Generated\ Files/
[Bb]uild[Ll]og.*
*.trx
+# Code Coverage Results
+TestResults/
+
# NUnit
*.VisualState.xml
TestResult.xml
diff --git a/AbeckDev.DbTimetable.Mcp.Test/AbeckDev.DbTimetable.Mcp.Test.csproj b/AbeckDev.DbTimetable.Mcp.Test/AbeckDev.DbTimetable.Mcp.Test.csproj
new file mode 100644
index 0000000..d8b852d
--- /dev/null
+++ b/AbeckDev.DbTimetable.Mcp.Test/AbeckDev.DbTimetable.Mcp.Test.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AbeckDev.DbTimetable.Mcp.Test/ConfigurationTests.cs b/AbeckDev.DbTimetable.Mcp.Test/ConfigurationTests.cs
new file mode 100644
index 0000000..886b8c5
--- /dev/null
+++ b/AbeckDev.DbTimetable.Mcp.Test/ConfigurationTests.cs
@@ -0,0 +1,45 @@
+using AbeckDev.DbTimetable.Mcp.Models;
+
+namespace AbeckDev.DbTimetable.Mcp.Test;
+
+public class ConfigurationTests
+{
+ [Fact]
+ public void Configuration_SectionName_HasCorrectValue()
+ {
+ // Arrange & Act & Assert
+ Assert.Equal("DeutscheBahnApi", Configuration.SectionName);
+ }
+
+ [Fact]
+ public void Configuration_DefaultValues_AreCorrect()
+ {
+ // Arrange & Act
+ var config = new Configuration();
+
+ // Assert
+ Assert.Equal("https://apis.deutschebahn.com/db-api-marketplace/apis/timetables/v1/", config.BaseUrl);
+ Assert.Equal(string.Empty, config.ClientId);
+ Assert.Equal(string.Empty, config.ApiKey);
+ }
+
+ [Fact]
+ public void Configuration_Properties_CanBeSet()
+ {
+ // Arrange
+ var config = new Configuration();
+ var expectedBaseUrl = "https://test.api.com/";
+ var expectedClientId = "test-client-id";
+ var expectedApiKey = "test-api-key";
+
+ // Act
+ config.BaseUrl = expectedBaseUrl;
+ config.ClientId = expectedClientId;
+ config.ApiKey = expectedApiKey;
+
+ // Assert
+ Assert.Equal(expectedBaseUrl, config.BaseUrl);
+ Assert.Equal(expectedClientId, config.ClientId);
+ Assert.Equal(expectedApiKey, config.ApiKey);
+ }
+}
diff --git a/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs
new file mode 100644
index 0000000..6bcd616
--- /dev/null
+++ b/AbeckDev.DbTimetable.Mcp.Test/TimeTableServiceTests.cs
@@ -0,0 +1,363 @@
+using System.Net;
+using AbeckDev.DbTimetable.Mcp.Models;
+using AbeckDev.DbTimetable.Mcp.Services;
+using Microsoft.Extensions.Options;
+using Moq;
+using Moq.Protected;
+
+namespace AbeckDev.DbTimetable.Mcp.Test;
+
+public class TimeTableServiceTests
+{
+ private readonly Mock> _mockOptions;
+ private readonly Configuration _config;
+ private readonly string _testXmlResponse = "Test Station";
+
+ public TimeTableServiceTests()
+ {
+ _config = new Configuration
+ {
+ BaseUrl = "https://test.api.com/",
+ ClientId = "test-client-id",
+ ApiKey = "test-api-key"
+ };
+ _mockOptions = new Mock>();
+ _mockOptions.Setup(o => o.Value).Returns(_config);
+ }
+
+ private HttpClient CreateMockHttpClient(HttpStatusCode statusCode, string content)
+ {
+ var mockHandler = new Mock();
+ mockHandler.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = statusCode,
+ Content = new StringContent(content)
+ });
+
+ return new HttpClient(mockHandler.Object)
+ {
+ BaseAddress = new Uri(_config.BaseUrl)
+ };
+ }
+
+ private void VerifyHttpRequest(Mock mockHandler, string expectedPath, string expectedClientId, string expectedApiKey)
+ {
+ mockHandler.Protected().Verify(
+ "SendAsync",
+ Times.Once(),
+ ItExpr.Is(req =>
+ req.RequestUri!.PathAndQuery.Contains(expectedPath) &&
+ req.Headers.Contains("DB-Client-Id") &&
+ req.Headers.GetValues("DB-Client-Id").First() == expectedClientId &&
+ req.Headers.Contains("DB-Api-Key") &&
+ req.Headers.GetValues("DB-Api-Key").First() == expectedApiKey &&
+ req.Headers.Accept.Any(h => h.MediaType == "application/xml")),
+ ItExpr.IsAny());
+ }
+
+ [Fact]
+ public async Task GetRecentTimetableChangesAsync_WithValidEventNo_ReturnsXmlContent()
+ {
+ // Arrange
+ var httpClient = CreateMockHttpClient(HttpStatusCode.OK, _testXmlResponse);
+ var service = new TimeTableService(httpClient, _mockOptions.Object);
+ var eventNo = "12345";
+
+ // Act
+ var result = await service.GetRecentTimetableChangesAsync(eventNo);
+
+ // Assert
+ Assert.Equal(_testXmlResponse, result);
+ }
+
+ [Fact]
+ public async Task GetRecentTimetableChangesAsync_WithHttpError_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var mockHandler = new Mock();
+ mockHandler.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.NotFound,
+ Content = new StringContent("Not Found")
+ });
+
+ var httpClient = new HttpClient(mockHandler.Object)
+ {
+ BaseAddress = new Uri(_config.BaseUrl)
+ };
+ var service = new TimeTableService(httpClient, _mockOptions.Object);
+ var eventNo = "99999";
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ service.GetRecentTimetableChangesAsync(eventNo));
+ }
+
+ [Fact]
+ public async Task GetStationBoardAsync_WithoutDate_UsesCurrentDate()
+ {
+ // Arrange
+ var httpClient = CreateMockHttpClient(HttpStatusCode.OK, _testXmlResponse);
+ var service = new TimeTableService(httpClient, _mockOptions.Object);
+ var evaNo = "8000105";
+
+ // Act
+ var result = await service.GetStationBoardAsync(evaNo);
+
+ // Assert
+ Assert.Equal(_testXmlResponse, result);
+ }
+
+ [Fact]
+ public async Task GetStationBoardAsync_WithDate_UsesProvidedDate()
+ {
+ // 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";
+ var testDate = new DateTime(2025, 11, 5, 18, 30, 0);
+
+ // Act
+ var result = await service.GetStationBoardAsync(evaNo, testDate);
+
+ // Assert
+ Assert.Equal(_testXmlResponse, result);
+
+ // Verify the request path contains the formatted date
+ mockHandler.Protected().Verify(
+ "SendAsync",
+ Times.Once(),
+ ItExpr.Is(req =>
+ req.RequestUri!.PathAndQuery.Contains("plan/8000105/251105/18")),
+ ItExpr.IsAny());
+ }
+
+ [Fact]
+ public async Task GetStationBoardAsync_WithHttpError_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var mockHandler = new Mock();
+ mockHandler.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.InternalServerError,
+ Content = new StringContent("Server Error")
+ });
+
+ var httpClient = new HttpClient(mockHandler.Object)
+ {
+ BaseAddress = new Uri(_config.BaseUrl)
+ };
+ var service = new TimeTableService(httpClient, _mockOptions.Object);
+ var evaNo = "8000105";
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ service.GetStationBoardAsync(evaNo));
+ }
+
+ [Fact]
+ public async Task GetFullChangesAsync_WithValidEvaNo_ReturnsXmlContent()
+ {
+ // Arrange
+ var httpClient = CreateMockHttpClient(HttpStatusCode.OK, _testXmlResponse);
+ var service = new TimeTableService(httpClient, _mockOptions.Object);
+ var evaNo = "8000105";
+
+ // Act
+ var result = await service.GetFullChangesAsync(evaNo);
+
+ // Assert
+ Assert.Equal(_testXmlResponse, result);
+ }
+
+ [Fact]
+ public async Task GetFullChangesAsync_WithHttpError_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var mockHandler = new Mock();
+ mockHandler.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.Unauthorized,
+ Content = new StringContent("Unauthorized")
+ });
+
+ var httpClient = new HttpClient(mockHandler.Object)
+ {
+ BaseAddress = new Uri(_config.BaseUrl)
+ };
+ var service = new TimeTableService(httpClient, _mockOptions.Object);
+ var evaNo = "8000105";
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ service.GetFullChangesAsync(evaNo));
+ }
+
+ [Fact]
+ public async Task GetStationInformation_WithValidPattern_ReturnsXmlContent()
+ {
+ // Arrange
+ var httpClient = CreateMockHttpClient(HttpStatusCode.OK, _testXmlResponse);
+ var service = new TimeTableService(httpClient, _mockOptions.Object);
+ var pattern = "Frankfurt";
+
+ // Act
+ var result = await service.GetStationInformation(pattern);
+
+ // Assert
+ Assert.Equal(_testXmlResponse, result);
+ }
+
+ [Fact]
+ public async Task GetStationInformation_WithHttpError_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var mockHandler = new Mock();
+ mockHandler.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.BadRequest,
+ Content = new StringContent("Bad Request")
+ });
+
+ var httpClient = new HttpClient(mockHandler.Object)
+ {
+ BaseAddress = new Uri(_config.BaseUrl)
+ };
+ var service = new TimeTableService(httpClient, _mockOptions.Object);
+ var pattern = "InvalidPattern";
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ service.GetStationInformation(pattern));
+ }
+
+ [Fact]
+ public async Task GetRecentTimetableChangesAsync_SetsCorrectHeaders()
+ {
+ // 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 eventNo = "12345";
+
+ // Act
+ await service.GetRecentTimetableChangesAsync(eventNo);
+
+ // Assert
+ VerifyHttpRequest(mockHandler, $"rchg/{eventNo}", _config.ClientId, _config.ApiKey);
+ }
+
+ [Fact]
+ public async Task GetFullChangesAsync_SetsCorrectHeaders()
+ {
+ // 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";
+
+ // Act
+ await service.GetFullChangesAsync(evaNo);
+
+ // Assert
+ VerifyHttpRequest(mockHandler, $"fchg/{evaNo}", _config.ClientId, _config.ApiKey);
+ }
+
+ [Fact]
+ public async Task GetStationInformation_SetsCorrectHeaders()
+ {
+ // 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 pattern = "Frankfurt";
+
+ // Act
+ await service.GetStationInformation(pattern);
+
+ // Assert
+ VerifyHttpRequest(mockHandler, $"station/{pattern}", _config.ClientId, _config.ApiKey);
+ }
+}
diff --git a/AbeckDev.DbTimetable.Mcp.Test/TimetableToolsTests.cs b/AbeckDev.DbTimetable.Mcp.Test/TimetableToolsTests.cs
new file mode 100644
index 0000000..2ee93f5
--- /dev/null
+++ b/AbeckDev.DbTimetable.Mcp.Test/TimetableToolsTests.cs
@@ -0,0 +1,295 @@
+using AbeckDev.DbTimetable.Mcp.Services;
+using Moq;
+
+namespace AbeckDev.DbTimetable.Mcp.Test;
+
+public class TimetableToolsTests
+{
+ private readonly Mock _mockService;
+ private readonly string _testXmlResponse = "Test Station";
+
+ public TimetableToolsTests()
+ {
+ _mockService = new Mock(MockBehavior.Strict);
+ }
+
+ [Fact]
+ public async Task GetFullTimetableChanges_WithValidEventNo_ReturnsXmlContent()
+ {
+ // Arrange
+ var eventNo = "12345";
+ _mockService.Setup(s => s.GetFullChangesAsync(eventNo, default))
+ .ReturnsAsync(_testXmlResponse);
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetFullTimetableChanges(eventNo);
+
+ // Assert
+ Assert.Equal(_testXmlResponse, result);
+ _mockService.Verify(s => s.GetFullChangesAsync(eventNo, default), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetFullTimetableChanges_WithHttpRequestException_ReturnsErrorMessage()
+ {
+ // Arrange
+ var eventNo = "12345";
+ var exceptionMessage = "Network error occurred";
+ _mockService.Setup(s => s.GetFullChangesAsync(eventNo, default))
+ .ThrowsAsync(new HttpRequestException(exceptionMessage));
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetFullTimetableChanges(eventNo);
+
+ // Assert
+ Assert.Contains("Error fetching timetable changes:", result);
+ Assert.Contains(exceptionMessage, result);
+ }
+
+ [Fact]
+ public async Task GetFullTimetableChanges_WithGeneralException_ReturnsUnexpectedErrorMessage()
+ {
+ // Arrange
+ var eventNo = "12345";
+ var exceptionMessage = "Unexpected error";
+ _mockService.Setup(s => s.GetFullChangesAsync(eventNo, default))
+ .ThrowsAsync(new InvalidOperationException(exceptionMessage));
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetFullTimetableChanges(eventNo);
+
+ // Assert
+ Assert.Contains("Unexpected error:", result);
+ Assert.Contains(exceptionMessage, result);
+ }
+
+ [Fact]
+ public async Task GetStationBoard_WithValidEvaNoAndNoDateTime_ReturnsXmlContent()
+ {
+ // Arrange
+ var evaNo = "8000105";
+ _mockService.Setup(s => s.GetStationBoardAsync(evaNo, null, default))
+ .ReturnsAsync(_testXmlResponse);
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationBoard(evaNo, null);
+
+ // Assert
+ Assert.Equal(_testXmlResponse, result);
+ _mockService.Verify(s => s.GetStationBoardAsync(evaNo, null, default), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetStationBoard_WithValidEvaNoAndDateTime_ParsesDateTimeAndReturnsXmlContent()
+ {
+ // Arrange
+ var evaNo = "8000105";
+ var dateTimeString = "2025-11-05 18:30";
+ var parsedDateTime = DateTime.Parse(dateTimeString);
+
+ _mockService.Setup(s => s.GetStationBoardAsync(evaNo, parsedDateTime, default))
+ .ReturnsAsync(_testXmlResponse);
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationBoard(evaNo, dateTimeString);
+
+ // Assert
+ Assert.Equal(_testXmlResponse, result);
+ _mockService.Verify(s => s.GetStationBoardAsync(evaNo, parsedDateTime, default), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetStationBoard_WithInvalidDateTimeFormat_ReturnsErrorMessage()
+ {
+ // Arrange
+ var evaNo = "8000105";
+ var invalidDateTime = "invalid-date-format";
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationBoard(evaNo, invalidDateTime);
+
+ // Assert
+ Assert.Contains("Error: Invalid date format", result);
+ Assert.Contains("yyyy-MM-dd HH:mm", result);
+ }
+
+ [Fact]
+ public async Task GetStationBoard_WithEmptyDateTime_CallsServiceWithNull()
+ {
+ // Arrange
+ var evaNo = "8000105";
+ _mockService.Setup(s => s.GetStationBoardAsync(evaNo, null, default))
+ .ReturnsAsync(_testXmlResponse);
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationBoard(evaNo, "");
+
+ // Assert
+ Assert.Equal(_testXmlResponse, result);
+ _mockService.Verify(s => s.GetStationBoardAsync(evaNo, null, default), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetStationBoard_WithHttpRequestException_ReturnsErrorMessage()
+ {
+ // Arrange
+ var evaNo = "8000105";
+ var exceptionMessage = "Station not found";
+ _mockService.Setup(s => s.GetStationBoardAsync(evaNo, null, default))
+ .ThrowsAsync(new HttpRequestException(exceptionMessage));
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationBoard(evaNo, null);
+
+ // Assert
+ Assert.Contains("Error fetching station board:", result);
+ Assert.Contains(exceptionMessage, result);
+ }
+
+ [Fact]
+ public async Task GetStationBoard_WithGeneralException_ReturnsUnexpectedErrorMessage()
+ {
+ // Arrange
+ var evaNo = "8000105";
+ var exceptionMessage = "Unexpected error";
+ _mockService.Setup(s => s.GetStationBoardAsync(evaNo, null, default))
+ .ThrowsAsync(new InvalidOperationException(exceptionMessage));
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationBoard(evaNo, null);
+
+ // Assert
+ Assert.Contains("Unexpected error:", result);
+ Assert.Contains(exceptionMessage, result);
+ }
+
+ [Fact]
+ public async Task GetStationChanges_WithValidEvaNo_ReturnsXmlContent()
+ {
+ // Arrange
+ var evaNo = "8000105";
+ _mockService.Setup(s => s.GetRecentTimetableChangesAsync(evaNo, default))
+ .ReturnsAsync(_testXmlResponse);
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationChanges(evaNo);
+
+ // Assert
+ Assert.Equal(_testXmlResponse, result);
+ _mockService.Verify(s => s.GetRecentTimetableChangesAsync(evaNo, default), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetStationChanges_WithHttpRequestException_ReturnsErrorMessage()
+ {
+ // Arrange
+ var evaNo = "8000105";
+ var exceptionMessage = "API rate limit exceeded";
+ _mockService.Setup(s => s.GetRecentTimetableChangesAsync(evaNo, default))
+ .ThrowsAsync(new HttpRequestException(exceptionMessage));
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationChanges(evaNo);
+
+ // Assert
+ Assert.Contains("Error fetching station changes:", result);
+ Assert.Contains(exceptionMessage, result);
+ }
+
+ [Fact]
+ public async Task GetStationChanges_WithGeneralException_ReturnsUnexpectedErrorMessage()
+ {
+ // Arrange
+ var evaNo = "8000105";
+ var exceptionMessage = "Unexpected error";
+ _mockService.Setup(s => s.GetRecentTimetableChangesAsync(evaNo, default))
+ .ThrowsAsync(new InvalidOperationException(exceptionMessage));
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationChanges(evaNo);
+
+ // Assert
+ Assert.Contains("Unexpected error:", result);
+ Assert.Contains(exceptionMessage, result);
+ }
+
+ [Fact]
+ public async Task GetStationDetails_WithValidPattern_ReturnsXmlContent()
+ {
+ // Arrange
+ var pattern = "Frankfurt";
+ _mockService.Setup(s => s.GetStationInformation(pattern, default))
+ .ReturnsAsync(_testXmlResponse);
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationDetails(pattern);
+
+ // Assert
+ Assert.Equal(_testXmlResponse, result);
+ _mockService.Verify(s => s.GetStationInformation(pattern, default), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetStationDetails_WithHttpRequestException_ReturnsErrorMessage()
+ {
+ // Arrange
+ var pattern = "Frankfurt";
+ var exceptionMessage = "Service unavailable";
+ _mockService.Setup(s => s.GetStationInformation(pattern, default))
+ .ThrowsAsync(new HttpRequestException(exceptionMessage));
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationDetails(pattern);
+
+ // Assert
+ Assert.Contains("Error fetching station details:", result);
+ Assert.Contains(exceptionMessage, result);
+ }
+
+ [Fact]
+ public async Task GetStationDetails_WithGeneralException_ReturnsUnexpectedErrorMessage()
+ {
+ // Arrange
+ var pattern = "Frankfurt";
+ var exceptionMessage = "Unexpected error";
+ _mockService.Setup(s => s.GetStationInformation(pattern, default))
+ .ThrowsAsync(new InvalidOperationException(exceptionMessage));
+
+ var tools = new Tools.TimetableTools(_mockService.Object);
+
+ // Act
+ var result = await tools.GetStationDetails(pattern);
+
+ // Assert
+ Assert.Contains("Unexpected error:", result);
+ Assert.Contains(exceptionMessage, result);
+ }
+}
diff --git a/AbeckDev.DbTimetable.Mcp/Program.cs b/AbeckDev.DbTimetable.Mcp/Program.cs
index 3e2a347..dd67f8f 100644
--- a/AbeckDev.DbTimetable.Mcp/Program.cs
+++ b/AbeckDev.DbTimetable.Mcp/Program.cs
@@ -19,7 +19,7 @@
.GetSection(Configuration.SectionName)
.Get() ?? new Configuration();
-builder.Services.AddHttpClient(client =>
+builder.Services.AddHttpClient(client =>
{
client.BaseAddress = new Uri(dbConfig.BaseUrl);
client.DefaultRequestHeaders.Accept.Add(
diff --git a/AbeckDev.DbTimetable.Mcp/Services/ITimeTableService.cs b/AbeckDev.DbTimetable.Mcp/Services/ITimeTableService.cs
new file mode 100644
index 0000000..67b0b5d
--- /dev/null
+++ b/AbeckDev.DbTimetable.Mcp/Services/ITimeTableService.cs
@@ -0,0 +1,24 @@
+namespace AbeckDev.DbTimetable.Mcp.Services;
+
+public interface ITimeTableService
+{
+ ///
+ /// Get recent timetable changes for a specific event number
+ ///
+ Task GetRecentTimetableChangesAsync(string eventNo, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get station board (departures/arrivals) for a specific station
+ ///
+ Task GetStationBoardAsync(string evaNo, DateTime? date = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get full changes for a station at a specific time
+ ///
+ Task GetFullChangesAsync(string evaNo, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get information about stations given either a station name (prefix), eva number, ds100/rl100 code, wildcard (*); doesn't seem to work with umlauten in station name (prefix)
+ ///
+ Task GetStationInformation(string pattern, CancellationToken cancellationToken = default);
+}
diff --git a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs
index 4702a22..97890c9 100644
--- a/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs
+++ b/AbeckDev.DbTimetable.Mcp/Services/TimeTableService.cs
@@ -4,7 +4,7 @@
namespace AbeckDev.DbTimetable.Mcp.Services;
-public class TimeTableService
+public class TimeTableService : ITimeTableService
{
private readonly HttpClient _httpClient;
diff --git a/AbeckDev.DbTimetable.Mcp/Tools.cs b/AbeckDev.DbTimetable.Mcp/Tools.cs
index 98023d4..4a786f5 100644
--- a/AbeckDev.DbTimetable.Mcp/Tools.cs
+++ b/AbeckDev.DbTimetable.Mcp/Tools.cs
@@ -15,9 +15,9 @@ public static class Tools
[McpServerToolType]
public class TimetableTools
{
- private readonly TimeTableService _timeTableService;
+ private readonly ITimeTableService _timeTableService;
- public TimetableTools(TimeTableService timeTableService)
+ public TimetableTools(ITimeTableService timeTableService)
{
_timeTableService = timeTableService;
}
@@ -106,7 +106,7 @@ public async Task GetStationBoard(
}
catch (HttpRequestException ex)
{
- return $"Error fetching station Details: {ex.Message}";
+ return $"Error fetching station details: {ex.Message}";
}
catch (Exception ex)
{
diff --git a/AbeckDev.DbTimetable.sln b/AbeckDev.DbTimetable.sln
index 1428132..3482bd3 100644
--- a/AbeckDev.DbTimetable.sln
+++ b/AbeckDev.DbTimetable.sln
@@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AbeckDev.DbTimetable.Mcp", "AbeckDev.DbTimetable.Mcp\AbeckDev.DbTimetable.Mcp.csproj", "{B227F67C-ADC2-4508-8B5E-A35D03207228}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AbeckDev.DbTimetable.Mcp.Test", "AbeckDev.DbTimetable.Mcp.Test\AbeckDev.DbTimetable.Mcp.Test.csproj", "{5D35B905-9764-4678-84FF-745689BFB2AD}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -27,6 +29,18 @@ Global
{B227F67C-ADC2-4508-8B5E-A35D03207228}.Release|x64.Build.0 = Release|Any CPU
{B227F67C-ADC2-4508-8B5E-A35D03207228}.Release|x86.ActiveCfg = Release|Any CPU
{B227F67C-ADC2-4508-8B5E-A35D03207228}.Release|x86.Build.0 = Release|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Debug|x64.Build.0 = Debug|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Debug|x86.Build.0 = Debug|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Release|x64.ActiveCfg = Release|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Release|x64.Build.0 = Release|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Release|x86.ActiveCfg = Release|Any CPU
+ {5D35B905-9764-4678-84FF-745689BFB2AD}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/README.md b/README.md
index 7692211..71bfd51 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,9 @@
# DB-TimetableAPI-MCPServer
+[](https://github.com/abeckDev/DB-TimetableAPI-MCPServer/actions/workflows/dotnet-ci.yml)
+[](https://opensource.org/licenses/MIT)
+[](https://codecov.io/github/abeckDev/DB-TimetableAPI-MCPServer)
+
> **Model Context Protocol (MCP) Server for Deutsche Bahn Timetable API Integration**
An MCP Server that bridges AI agents with the Deutsche Bahn Timetable API, enabling seamless access to German railway schedule data, real-time updates, and station information through a standardized protocol.
@@ -594,6 +598,93 @@ You can find more EVA numbers using the `GetStationDetails` tool with a station
---
+## ๐งช Testing
+
+### Running Tests Locally
+
+The project includes comprehensive unit tests with code coverage tracking.
+
+#### Run All Tests
+
+```bash
+# Navigate to the project directory
+cd DB-TimetableAPI-MCPServer
+
+# Run all tests
+dotnet test
+```
+
+#### Run Tests with Code Coverage
+
+```bash
+# Run tests and collect coverage data
+dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults
+
+# Generate HTML coverage report (requires reportgenerator tool)
+dotnet tool install --global dotnet-reportgenerator-globaltool
+reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:"TestResults/CoverageReport" -reporttypes:"Html;TextSummary"
+
+# View coverage summary
+cat TestResults/CoverageReport/Summary.txt
+
+# Open HTML report in browser
+# The report will be at: TestResults/CoverageReport/index.html
+```
+
+### Test Structure
+
+The test project (`AbeckDev.DbTimetable.Mcp.Test`) includes:
+
+- **ConfigurationTests.cs**: Tests for configuration model validation
+- **TimeTableServiceTests.cs**: Tests for API service layer with mocked HTTP responses
+- **TimetableToolsTests.cs**: Tests for MCP tool wrappers with error handling
+
+### Coverage Goals
+
+- **Current Coverage**: 78.3% line coverage
+- **Target**: 70%+ line coverage (enforced in CI/CD)
+- **Core Business Logic**: 100% coverage (Services, Tools, Models)
+
+### Testing Guidelines for Contributors
+
+When contributing code, please:
+
+1. **Write Tests**: Add unit tests for any new functionality
+2. **Mock External Dependencies**: Use Moq to mock HTTP clients and external services
+3. **Test Error Scenarios**: Include tests for both success and failure cases
+4. **Maintain Coverage**: Ensure your changes don't drop overall coverage below 70%
+5. **Run Tests Locally**: Verify all tests pass before submitting a PR
+
+Example test pattern:
+
+```csharp
+[Fact]
+public async Task MethodName_WithCondition_ExpectedBehavior()
+{
+ // Arrange
+ var mockService = new Mock();
+ mockService.Setup(s => s.MethodAsync(...)).ReturnsAsync(...);
+
+ // Act
+ var result = await service.MethodAsync(...);
+
+ // Assert
+ Assert.Equal(expectedValue, result);
+}
+```
+
+### Continuous Integration
+
+All pull requests automatically run:
+- โ
Build verification
+- โ
All unit tests
+- โ
Code coverage analysis
+- โ
Coverage threshold checks (70% minimum)
+
+Coverage reports are available as workflow artifacts.
+
+---
+
## ๐ค Contributing
We welcome contributions from the community!