diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs
index 4c8332e..f054866 100644
--- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs
@@ -172,6 +172,77 @@ ... on Commit {
}
}
+ ///
+ /// Gets all releases for a repository using GraphQL with pagination.
+ ///
+ /// Repository owner.
+ /// Repository name.
+ /// List of release nodes.
+ public async Task> GetReleasesAsync(
+ string owner,
+ string repo)
+ {
+ try
+ {
+ var allReleaseNodes = new List();
+ string? afterCursor = null;
+ bool hasNextPage;
+
+ // Paginate through all releases
+ do
+ {
+ // Create GraphQL request to get releases for a repository with pagination support
+ var request = new GraphQLRequest
+ {
+ Query = @"
+ query($owner: String!, $repo: String!, $after: String) {
+ repository(owner: $owner, name: $repo) {
+ releases(first: 100, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) {
+ nodes {
+ tagName
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+ }",
+ Variables = new
+ {
+ owner,
+ repo,
+ after = afterCursor
+ }
+ };
+
+ // Execute GraphQL query
+ var response = await _graphqlClient.SendQueryAsync(request);
+
+ // Extract release nodes from the GraphQL response, filtering out null or invalid values
+ var pageReleaseNodes = response.Data?.Repository?.Releases?.Nodes?
+ .Where(n => !string.IsNullOrEmpty(n.TagName))
+ .ToList() ?? [];
+
+ allReleaseNodes.AddRange(pageReleaseNodes);
+
+ // Check if there are more pages
+ var pageInfo = response.Data?.Repository?.Releases?.PageInfo;
+ hasNextPage = pageInfo?.HasNextPage ?? false;
+ afterCursor = pageInfo?.EndCursor;
+ }
+ while (hasNextPage);
+
+ // Return list of all release nodes
+ return allReleaseNodes;
+ }
+ catch
+ {
+ // If GraphQL query fails, return empty list
+ return [];
+ }
+ }
+
///
/// Finds issue IDs linked to a pull request via closingIssuesReferences.
///
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLTypes.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLTypes.cs
index 454bcd5..35a4064 100644
--- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLTypes.cs
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLTypes.cs
@@ -109,3 +109,33 @@ internal record CommitHistoryData(
/// Git object ID (SHA).
internal record CommitNode(
string? Oid);
+
+///
+/// Response for getting releases from a repository.
+///
+/// Repository data containing release information.
+internal record GetReleasesResponse(
+ ReleaseRepositoryData? Repository);
+
+///
+/// Repository data containing releases information.
+///
+/// Releases connection data.
+internal record ReleaseRepositoryData(
+ ReleasesConnectionData? Releases);
+
+///
+/// Releases connection data containing nodes and page info.
+///
+/// Release nodes.
+/// Pagination information.
+internal record ReleasesConnectionData(
+ List? Nodes,
+ PageInfo? PageInfo);
+
+///
+/// Release node containing release information.
+///
+/// Tag name associated with the release.
+internal record ReleaseNode(
+ string? TagName);
diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
index bcd98a4..ad26a2b 100644
--- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
+++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs
@@ -131,7 +131,7 @@ internal sealed record Commit(
///
internal sealed record GitHubData(
IReadOnlyList Commits,
- IReadOnlyList Releases,
+ IReadOnlyList Releases,
IReadOnlyList Tags,
IReadOnlyList PullRequests,
IReadOnlyList Issues);
@@ -142,9 +142,9 @@ internal sealed record GitHubData(
internal sealed record LookupData(
Dictionary IssueById,
Dictionary CommitHashToPr,
- List BranchReleases,
+ List BranchReleases,
Dictionary TagsByName,
- Dictionary TagToRelease,
+ Dictionary TagToRelease,
List ReleaseVersions,
HashSet BranchTagNames);
@@ -166,7 +166,7 @@ private static async Task FetchGitHubDataAsync(
{
// Fetch all data from GitHub in parallel
var commitsTask = GetAllCommitsAsync(graphqlClient, owner, repo, branch);
- var releasesTask = client.Repository.Release.GetAll(owner, repo);
+ var releasesTask = GetAllReleasesAsync(graphqlClient, owner, repo);
var tagsTask = client.Repository.GetAllTags(owner, repo);
var pullRequestsTask = client.PullRequest.GetAllForRepository(owner, repo, new PullRequestRequest { State = ItemStateFilter.All });
var issuesTask = client.Issue.GetAllForRepository(owner, repo, new RepositoryIssueRequest { State = ItemStateFilter.All });
@@ -214,7 +214,7 @@ internal static LookupData BuildLookupData(GitHubData data)
// Build an ordered list of releases on the current branch.
// This is used to select the prior release version for identifying changes in the build.
var branchReleases = data.Releases
- .Where(r => !string.IsNullOrEmpty(r.TagName) && branchTagNames.Contains(r.TagName))
+ .Where(r => r.TagName != null && branchTagNames.Contains(r.TagName))
.ToList();
// Build a mapping from tag name to tag object for quick lookup.
@@ -572,6 +572,22 @@ private static async Task> GetAllCommitsAsync(
return commitShas.Select(sha => new Commit(sha)).ToList();
}
+ ///
+ /// Gets all releases for a repository using GraphQL pagination.
+ ///
+ /// GitHub GraphQL client.
+ /// Repository owner.
+ /// Repository name.
+ /// List of all releases.
+ private static async Task> GetAllReleasesAsync(
+ GitHubGraphQLClient graphqlClient,
+ string owner,
+ string repo)
+ {
+ // Fetch all releases for the repository using GraphQL
+ return await graphqlClient.GetReleasesAsync(owner, repo);
+ }
+
///
/// Gets commits in the range from fromHash (exclusive) to toHash (inclusive).
///
diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientFindIssueIdsTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientFindIssueIdsTests.cs
new file mode 100644
index 0000000..6477b33
--- /dev/null
+++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientFindIssueIdsTests.cs
@@ -0,0 +1,436 @@
+// Copyright (c) 2026 DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+using System.Net;
+using System.Text;
+using DemaConsulting.BuildMark.RepoConnectors.GitHub;
+
+namespace DemaConsulting.BuildMark.Tests;
+
+///
+/// Tests for the GitHubGraphQLClient FindIssueIdsLinkedToPullRequestAsync method.
+///
+[TestClass]
+public class GitHubGraphQLClientFindIssueIdsTests
+{
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns expected issue IDs with valid response.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_ValidResponse_ReturnsIssueIds()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""pullRequest"": {
+ ""closingIssuesReferences"": {
+ ""nodes"": [
+ { ""number"": 123 },
+ { ""number"": 456 },
+ { ""number"": 789 }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.HasCount(3, issueIds);
+ Assert.AreEqual(123, issueIds[0]);
+ Assert.AreEqual(456, issueIds[1]);
+ Assert.AreEqual(789, issueIds[2]);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list when no issues are linked.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_NoIssues_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""pullRequest"": {
+ ""closingIssuesReferences"": {
+ ""nodes"": [],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.IsEmpty(issueIds);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list when response has missing data.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_MissingData_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": null
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.IsEmpty(issueIds);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list on HTTP error.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_HttpError_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{ ""message"": ""Not Found"" }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.IsEmpty(issueIds);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list on invalid JSON.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_InvalidJson_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = "This is not valid JSON";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.IsEmpty(issueIds);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync returns single issue ID correctly.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_SingleIssue_ReturnsOneIssueId()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""pullRequest"": {
+ ""closingIssuesReferences"": {
+ ""nodes"": [
+ { ""number"": 999 }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 1);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.HasCount(1, issueIds);
+ Assert.AreEqual(999, issueIds[0]);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync handles nodes with missing number property.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_MissingNumberProperty_SkipsInvalidNodes()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""pullRequest"": {
+ ""closingIssuesReferences"": {
+ ""nodes"": [
+ { ""number"": 100 },
+ { ""title"": ""Missing number"" },
+ { ""number"": 200 }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 5);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.HasCount(2, issueIds);
+ Assert.AreEqual(100, issueIds[0]);
+ Assert.AreEqual(200, issueIds[1]);
+ }
+
+ ///
+ /// Test that FindIssueIdsLinkedToPullRequestAsync handles pagination correctly.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_WithPagination_ReturnsAllIssues()
+ {
+ // Arrange - Create mock handler that returns different responses for different pages
+ var mockHandler = new PaginationMockHttpMessageHandler();
+ using var httpClient = new HttpClient(mockHandler);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 10);
+
+ // Assert
+ Assert.IsNotNull(issueIds);
+ Assert.HasCount(3, issueIds);
+ Assert.AreEqual(100, issueIds[0]);
+ Assert.AreEqual(200, issueIds[1]);
+ Assert.AreEqual(300, issueIds[2]);
+ }
+
+ ///
+ /// Creates a mock HttpClient with pre-canned response.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ /// HttpClient configured with mock handler.
+ private static HttpClient CreateMockHttpClient(string responseContent, HttpStatusCode statusCode)
+ {
+ var handler = new MockHttpMessageHandler(responseContent, statusCode);
+ return new HttpClient(handler);
+ }
+
+ ///
+ /// Mock HTTP message handler for testing.
+ ///
+ private sealed class MockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Response content to return.
+ ///
+ private readonly string _responseContent;
+
+ ///
+ /// HTTP status code to return.
+ ///
+ private readonly HttpStatusCode _statusCode;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ public MockHttpMessageHandler(string responseContent, HttpStatusCode statusCode)
+ {
+ _responseContent = responseContent;
+ _statusCode = statusCode;
+ }
+
+ ///
+ /// Sends a mock HTTP response.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // Create response with content
+ // Note: The returned HttpResponseMessage will be disposed by HttpClient,
+ // which also disposes the Content. This is the expected pattern for HttpMessageHandler.
+ var content = new StringContent(_responseContent, Encoding.UTF8, "application/json");
+ var response = new HttpResponseMessage(_statusCode)
+ {
+ Content = content
+ };
+
+ return Task.FromResult(response);
+ }
+ }
+
+ ///
+ /// Mock HTTP message handler for testing pagination.
+ ///
+ private sealed class PaginationMockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Request count to track pagination.
+ ///
+ private int _requestCount;
+
+ ///
+ /// Sends a mock HTTP response with pagination.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // Read request body to determine which page to return
+ var requestBody = request.Content != null
+ ? await request.Content.ReadAsStringAsync(cancellationToken)
+ : string.Empty;
+
+ string responseContent;
+ if (_requestCount == 0 || !requestBody.Contains("\"after\""))
+ {
+ // First page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""pullRequest"": {
+ ""closingIssuesReferences"": {
+ ""nodes"": [
+ { ""number"": 100 }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": true,
+ ""endCursor"": ""cursor1""
+ }
+ }
+ }
+ }
+ }
+ }";
+ }
+ else if (requestBody.Contains("\"cursor1\""))
+ {
+ // Second page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""pullRequest"": {
+ ""closingIssuesReferences"": {
+ ""nodes"": [
+ { ""number"": 200 }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": true,
+ ""endCursor"": ""cursor2""
+ }
+ }
+ }
+ }
+ }
+ }";
+ }
+ else
+ {
+ // Third (last) page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""pullRequest"": {
+ ""closingIssuesReferences"": {
+ ""nodes"": [
+ { ""number"": 300 }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }
+ }";
+ }
+
+ _requestCount++;
+
+ // Create response with content
+ // Note: The returned HttpResponseMessage will be disposed by HttpClient,
+ // which also disposes the Content. This is the expected pattern for HttpMessageHandler.
+ var content = new StringContent(responseContent, Encoding.UTF8, "application/json");
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = content
+ };
+
+ return response;
+ }
+ }
+}
diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetCommitsTests.cs
similarity index 56%
rename from test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
rename to test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetCommitsTests.cs
index 930ff94..da3d0bd 100644
--- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs
+++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetCommitsTests.cs
@@ -25,415 +25,11 @@
namespace DemaConsulting.BuildMark.Tests;
///
-/// Tests for the GitHubGraphQLClient class.
+/// Tests for the GitHubGraphQLClient GetCommitsAsync method.
///
[TestClass]
-public class GitHubGraphQLClientTests
+public class GitHubGraphQLClientGetCommitsTests
{
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns expected issue IDs with valid response.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_ValidResponse_ReturnsIssueIds()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""pullRequest"": {
- ""closingIssuesReferences"": {
- ""nodes"": [
- { ""number"": 123 },
- { ""number"": 456 },
- { ""number"": 789 }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.HasCount(3, issueIds);
- Assert.AreEqual(123, issueIds[0]);
- Assert.AreEqual(456, issueIds[1]);
- Assert.AreEqual(789, issueIds[2]);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list when no issues are linked.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_NoIssues_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""pullRequest"": {
- ""closingIssuesReferences"": {
- ""nodes"": [],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.IsEmpty(issueIds);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list when response has missing data.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_MissingData_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": null
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.IsEmpty(issueIds);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list on HTTP error.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_HttpError_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = @"{ ""message"": ""Not Found"" }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.IsEmpty(issueIds);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns empty list on invalid JSON.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_InvalidJson_ReturnsEmptyList()
- {
- // Arrange
- var mockResponse = "This is not valid JSON";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 42);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.IsEmpty(issueIds);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync returns single issue ID correctly.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_SingleIssue_ReturnsOneIssueId()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""pullRequest"": {
- ""closingIssuesReferences"": {
- ""nodes"": [
- { ""number"": 999 }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 1);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.HasCount(1, issueIds);
- Assert.AreEqual(999, issueIds[0]);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync handles nodes with missing number property.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_MissingNumberProperty_SkipsInvalidNodes()
- {
- // Arrange
- var mockResponse = @"{
- ""data"": {
- ""repository"": {
- ""pullRequest"": {
- ""closingIssuesReferences"": {
- ""nodes"": [
- { ""number"": 100 },
- { ""title"": ""Missing number"" },
- { ""number"": 200 }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }";
-
- using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 5);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.HasCount(2, issueIds);
- Assert.AreEqual(100, issueIds[0]);
- Assert.AreEqual(200, issueIds[1]);
- }
-
- ///
- /// Test that FindIssueIdsLinkedToPullRequestAsync handles pagination correctly.
- ///
- [TestMethod]
- public async Task GitHubGraphQLClient_FindIssueIdsLinkedToPullRequestAsync_WithPagination_ReturnsAllIssues()
- {
- // Arrange - Create mock handler that returns different responses for different pages
- var mockHandler = new PaginationMockHttpMessageHandler();
- using var httpClient = new HttpClient(mockHandler);
- using var client = new GitHubGraphQLClient(httpClient);
-
- // Act
- var issueIds = await client.FindIssueIdsLinkedToPullRequestAsync("owner", "repo", 10);
-
- // Assert
- Assert.IsNotNull(issueIds);
- Assert.HasCount(3, issueIds);
- Assert.AreEqual(100, issueIds[0]);
- Assert.AreEqual(200, issueIds[1]);
- Assert.AreEqual(300, issueIds[2]);
- }
-
- ///
- /// Creates a mock HttpClient with pre-canned response.
- ///
- /// Response content to return.
- /// HTTP status code to return.
- /// HttpClient configured with mock handler.
- private static HttpClient CreateMockHttpClient(string responseContent, HttpStatusCode statusCode)
- {
- var handler = new MockHttpMessageHandler(responseContent, statusCode);
- return new HttpClient(handler);
- }
-
- ///
- /// Mock HTTP message handler for testing.
- ///
- private sealed class MockHttpMessageHandler : HttpMessageHandler
- {
- ///
- /// Response content to return.
- ///
- private readonly string _responseContent;
-
- ///
- /// HTTP status code to return.
- ///
- private readonly HttpStatusCode _statusCode;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Response content to return.
- /// HTTP status code to return.
- public MockHttpMessageHandler(string responseContent, HttpStatusCode statusCode)
- {
- _responseContent = responseContent;
- _statusCode = statusCode;
- }
-
- ///
- /// Sends a mock HTTP response.
- ///
- /// HTTP request message.
- /// Cancellation token.
- /// Mock HTTP response.
- protected override Task SendAsync(
- HttpRequestMessage request,
- CancellationToken cancellationToken)
- {
- // Create response with content
- // Note: The returned HttpResponseMessage will be disposed by HttpClient,
- // which also disposes the Content. This is the expected pattern for HttpMessageHandler.
- var content = new StringContent(_responseContent, Encoding.UTF8, "application/json");
- var response = new HttpResponseMessage(_statusCode)
- {
- Content = content
- };
-
- return Task.FromResult(response);
- }
- }
-
- ///
- /// Mock HTTP message handler for testing pagination.
- ///
- private sealed class PaginationMockHttpMessageHandler : HttpMessageHandler
- {
- ///
- /// Request count to track pagination.
- ///
- private int _requestCount;
-
- ///
- /// Sends a mock HTTP response with pagination.
- ///
- /// HTTP request message.
- /// Cancellation token.
- /// Mock HTTP response.
- protected override async Task SendAsync(
- HttpRequestMessage request,
- CancellationToken cancellationToken)
- {
- // Read request body to determine which page to return
- var requestBody = request.Content != null
- ? await request.Content.ReadAsStringAsync(cancellationToken)
- : string.Empty;
-
- string responseContent;
- if (_requestCount == 0 || !requestBody.Contains("\"after\""))
- {
- // First page
- responseContent = @"{
- ""data"": {
- ""repository"": {
- ""pullRequest"": {
- ""closingIssuesReferences"": {
- ""nodes"": [
- { ""number"": 100 }
- ],
- ""pageInfo"": {
- ""hasNextPage"": true,
- ""endCursor"": ""cursor1""
- }
- }
- }
- }
- }
- }";
- }
- else if (requestBody.Contains("\"cursor1\""))
- {
- // Second page
- responseContent = @"{
- ""data"": {
- ""repository"": {
- ""pullRequest"": {
- ""closingIssuesReferences"": {
- ""nodes"": [
- { ""number"": 200 }
- ],
- ""pageInfo"": {
- ""hasNextPage"": true,
- ""endCursor"": ""cursor2""
- }
- }
- }
- }
- }
- }";
- }
- else
- {
- // Third (last) page
- responseContent = @"{
- ""data"": {
- ""repository"": {
- ""pullRequest"": {
- ""closingIssuesReferences"": {
- ""nodes"": [
- { ""number"": 300 }
- ],
- ""pageInfo"": {
- ""hasNextPage"": false,
- ""endCursor"": null
- }
- }
- }
- }
- }
- }";
- }
-
- _requestCount++;
-
- // Create response with content
- // Note: The returned HttpResponseMessage will be disposed by HttpClient,
- // which also disposes the Content. This is the expected pattern for HttpMessageHandler.
- var content = new StringContent(responseContent, Encoding.UTF8, "application/json");
- var response = new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = content
- };
-
- return response;
- }
- }
-
///
/// Test that GetCommitsAsync returns expected commit SHAs with valid response.
///
@@ -704,7 +300,7 @@ protected override async Task SendAsync(
var requestBody = request.Content != null
? await request.Content.ReadAsStringAsync(cancellationToken)
: string.Empty;
-
+
string responseContent;
if (_requestCount == 0 || !requestBody.Contains("\"after\""))
{
@@ -790,4 +386,64 @@ protected override async Task SendAsync(
return response;
}
}
+ ///
+ /// Creates a mock HttpClient with pre-canned response.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ /// HttpClient configured with mock handler.
+ private static HttpClient CreateMockHttpClient(string responseContent, HttpStatusCode statusCode)
+ {
+ var handler = new MockHttpMessageHandler(responseContent, statusCode);
+ return new HttpClient(handler);
+ }
+
+ ///
+ /// Mock HTTP message handler for testing.
+ ///
+ private sealed class MockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Response content to return.
+ ///
+ private readonly string _responseContent;
+
+ ///
+ /// HTTP status code to return.
+ ///
+ private readonly HttpStatusCode _statusCode;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ public MockHttpMessageHandler(string responseContent, HttpStatusCode statusCode)
+ {
+ _responseContent = responseContent;
+ _statusCode = statusCode;
+ }
+
+ ///
+ /// Sends a mock HTTP response.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // Create response with content
+ // Note: The returned HttpResponseMessage will be disposed by HttpClient,
+ // which also disposes the Content. This is the expected pattern for HttpMessageHandler.
+ var content = new StringContent(_responseContent, Encoding.UTF8, "application/json");
+ var response = new HttpResponseMessage(_statusCode)
+ {
+ Content = content
+ };
+
+ return Task.FromResult(response);
+ }
+ }
}
diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetReleasesTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetReleasesTests.cs
new file mode 100644
index 0000000..23c228a
--- /dev/null
+++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetReleasesTests.cs
@@ -0,0 +1,421 @@
+// Copyright (c) 2026 DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+using System.Net;
+using System.Text;
+using DemaConsulting.BuildMark.RepoConnectors.GitHub;
+
+namespace DemaConsulting.BuildMark.Tests;
+
+///
+/// Tests for the GitHubGraphQLClient GetReleasesAsync method.
+///
+[TestClass]
+public class GitHubGraphQLClientGetReleasesTests
+{
+ ///
+ /// Test that GetReleasesAsync returns expected release tag names with valid response.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_ValidResponse_ReturnsReleaseTagNames()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v1.0.0"" },
+ { ""tagName"": ""v0.9.0"" },
+ { ""tagName"": ""v0.8.5"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(3, releaseNodes);
+ Assert.AreEqual("v1.0.0", releaseNodes[0].TagName);
+ Assert.AreEqual("v0.9.0", releaseNodes[1].TagName);
+ Assert.AreEqual("v0.8.5", releaseNodes[2].TagName);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list when no releases are found.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_NoReleases_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list when response has missing data.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_MissingData_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": null
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list on HTTP error.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_HttpError_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = @"{ ""message"": ""Not Found"" }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns empty list on invalid JSON.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_InvalidJson_ReturnsEmptyList()
+ {
+ // Arrange
+ var mockResponse = "This is not valid JSON";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.IsEmpty(releaseNodes);
+ }
+
+ ///
+ /// Test that GetReleasesAsync returns single release tag correctly.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_SingleRelease_ReturnsOneTagName()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v2.0.0-beta1"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(1, releaseNodes);
+ Assert.AreEqual("v2.0.0-beta1", releaseNodes[0].TagName);
+ }
+
+ ///
+ /// Test that GetReleasesAsync handles nodes with missing tagName property.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_MissingTagNameProperty_SkipsInvalidNodes()
+ {
+ // Arrange
+ var mockResponse = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v1.0.0"" },
+ { ""name"": ""Missing tag name"" },
+ { ""tagName"": ""v0.9.0"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+
+ using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(2, releaseNodes);
+ Assert.AreEqual("v1.0.0", releaseNodes[0].TagName);
+ Assert.AreEqual("v0.9.0", releaseNodes[1].TagName);
+ }
+
+ ///
+ /// Test that GetReleasesAsync handles pagination correctly.
+ ///
+ [TestMethod]
+ public async Task GitHubGraphQLClient_GetReleasesAsync_WithPagination_ReturnsAllReleases()
+ {
+ // Arrange - Create mock handler that returns different responses for different pages
+ var mockHandler = new ReleasePaginationMockHttpMessageHandler();
+ using var httpClient = new HttpClient(mockHandler);
+ using var client = new GitHubGraphQLClient(httpClient);
+
+ // Act
+ var releaseNodes = await client.GetReleasesAsync("owner", "repo");
+
+ // Assert
+ Assert.IsNotNull(releaseNodes);
+ Assert.HasCount(3, releaseNodes);
+ Assert.AreEqual("v3.0.0", releaseNodes[0].TagName);
+ Assert.AreEqual("v2.0.0", releaseNodes[1].TagName);
+ Assert.AreEqual("v1.0.0", releaseNodes[2].TagName);
+ }
+
+ ///
+ /// Mock HTTP message handler for testing release pagination.
+ ///
+ private sealed class ReleasePaginationMockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Request count to track pagination.
+ ///
+ private int _requestCount;
+
+ ///
+ /// Sends a mock HTTP response with pagination.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // Read request body to determine which page to return
+ var requestBody = request.Content != null
+ ? await request.Content.ReadAsStringAsync(cancellationToken)
+ : string.Empty;
+
+ string responseContent;
+ if (_requestCount == 0 || !requestBody.Contains("\"after\""))
+ {
+ // First page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v3.0.0"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": true,
+ ""endCursor"": ""cursor1""
+ }
+ }
+ }
+ }
+ }";
+ }
+ else if (requestBody.Contains("\"cursor1\""))
+ {
+ // Second page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v2.0.0"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": true,
+ ""endCursor"": ""cursor2""
+ }
+ }
+ }
+ }
+ }";
+ }
+ else
+ {
+ // Third (last) page
+ responseContent = @"{
+ ""data"": {
+ ""repository"": {
+ ""releases"": {
+ ""nodes"": [
+ { ""tagName"": ""v1.0.0"" }
+ ],
+ ""pageInfo"": {
+ ""hasNextPage"": false,
+ ""endCursor"": null
+ }
+ }
+ }
+ }
+ }";
+ }
+
+ _requestCount++;
+
+ // Create response with content
+ // Note: The returned HttpResponseMessage will be disposed by HttpClient,
+ // which also disposes the Content. This is the expected pattern for HttpMessageHandler.
+ var content = new StringContent(responseContent, Encoding.UTF8, "application/json");
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = content
+ };
+
+ return response;
+ }
+ }
+ ///
+ /// Creates a mock HttpClient with pre-canned response.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ /// HttpClient configured with mock handler.
+ private static HttpClient CreateMockHttpClient(string responseContent, HttpStatusCode statusCode)
+ {
+ var handler = new MockHttpMessageHandler(responseContent, statusCode);
+ return new HttpClient(handler);
+ }
+
+ ///
+ /// Mock HTTP message handler for testing.
+ ///
+ private sealed class MockHttpMessageHandler : HttpMessageHandler
+ {
+ ///
+ /// Response content to return.
+ ///
+ private readonly string _responseContent;
+
+ ///
+ /// HTTP status code to return.
+ ///
+ private readonly HttpStatusCode _statusCode;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Response content to return.
+ /// HTTP status code to return.
+ public MockHttpMessageHandler(string responseContent, HttpStatusCode statusCode)
+ {
+ _responseContent = responseContent;
+ _statusCode = statusCode;
+ }
+
+ ///
+ /// Sends a mock HTTP response.
+ ///
+ /// HTTP request message.
+ /// Cancellation token.
+ /// Mock HTTP response.
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ // Create response with content
+ // Note: The returned HttpResponseMessage will be disposed by HttpClient,
+ // which also disposes the Content. This is the expected pattern for HttpMessageHandler.
+ var content = new StringContent(_responseContent, Encoding.UTF8, "application/json");
+ var response = new HttpResponseMessage(_statusCode)
+ {
+ Content = content
+ };
+
+ return Task.FromResult(response);
+ }
+ }
+}