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); + } + } +}