From 93cb23662951d08ca069c9b2f4ad8a822d12a8d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 03:54:25 +0000 Subject: [PATCH 1/7] Initial plan From 4ae2fb2bb8db397d8d1a1c3af896cb216c4a1d86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 03:59:56 +0000 Subject: [PATCH 2/7] Add GraphQL releases query with pagination support Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- .../GitHub/GitHubGraphQLClient.cs | 72 ++++ .../GitHub/GitHubGraphQLTypes.cs | 30 ++ .../RepoConnectors/GitHubRepoConnector.cs | 26 +- .../GitHub/GitHubGraphQLClientTests.cs | 329 ++++++++++++++++++ 4 files changed, 456 insertions(+), 1 deletion(-) diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs index 4c8332e..01e622e 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs @@ -172,6 +172,78 @@ ... on Commit { } } + /// + /// Gets all releases for a repository using GraphQL with pagination. + /// + /// Repository owner. + /// Repository name. + /// List of release tag names. + public async Task> GetReleasesAsync( + string owner, + string repo) + { + try + { + var allReleaseTagNames = 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 tag names from the GraphQL response, filtering out null or invalid values + var pageReleaseTagNames = response.Data?.Repository?.Releases?.Nodes? + .Where(n => !string.IsNullOrEmpty(n.TagName)) + .Select(n => n.TagName!) + .ToList() ?? []; + + allReleaseTagNames.AddRange(pageReleaseTagNames); + + // 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 tag names + return allReleaseTagNames; + } + 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..65720e3 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs @@ -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 }); @@ -572,6 +572,30 @@ 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 release tag names for the repository using GraphQL + var releaseTagNames = await graphqlClient.GetReleasesAsync(owner, repo); + + // Convert tag names to Release objects using JSON deserialization + // This creates minimal Release objects with only TagName populated + return releaseTagNames.Select(tagName => + { + var json = $$"""{"tag_name":"{{tagName}}"}"""; + return System.Text.Json.JsonSerializer.Deserialize(json) ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}"); + }).ToList(); + } + /// /// Gets commits in the range from fromHash (exclusive) to toHash (inclusive). /// diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs index 930ff94..a5bee84 100644 --- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs +++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs @@ -790,4 +790,333 @@ protected override async Task SendAsync( return response; } } + + /// + /// 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 releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + + // Assert + Assert.IsNotNull(releaseTagNames); + Assert.HasCount(3, releaseTagNames); + Assert.AreEqual("v1.0.0", releaseTagNames[0]); + Assert.AreEqual("v0.9.0", releaseTagNames[1]); + Assert.AreEqual("v0.8.5", releaseTagNames[2]); + } + + /// + /// 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 releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + + // Assert + Assert.IsNotNull(releaseTagNames); + Assert.IsEmpty(releaseTagNames); + } + + /// + /// 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 releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + + // Assert + Assert.IsNotNull(releaseTagNames); + Assert.IsEmpty(releaseTagNames); + } + + /// + /// 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 releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + + // Assert + Assert.IsNotNull(releaseTagNames); + Assert.IsEmpty(releaseTagNames); + } + + /// + /// 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 releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + + // Assert + Assert.IsNotNull(releaseTagNames); + Assert.IsEmpty(releaseTagNames); + } + + /// + /// 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 releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + + // Assert + Assert.IsNotNull(releaseTagNames); + Assert.HasCount(1, releaseTagNames); + Assert.AreEqual("v2.0.0-beta1", releaseTagNames[0]); + } + + /// + /// 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 releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + + // Assert + Assert.IsNotNull(releaseTagNames); + Assert.HasCount(2, releaseTagNames); + Assert.AreEqual("v1.0.0", releaseTagNames[0]); + Assert.AreEqual("v0.9.0", releaseTagNames[1]); + } + + /// + /// 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 releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + + // Assert + Assert.IsNotNull(releaseTagNames); + Assert.HasCount(3, releaseTagNames); + Assert.AreEqual("v3.0.0", releaseTagNames[0]); + Assert.AreEqual("v2.0.0", releaseTagNames[1]); + Assert.AreEqual("v1.0.0", releaseTagNames[2]); + } + + /// + /// 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; + } + } } From ba5a8bbdd6b4a055e808be2ea764904943c1e4b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 04:03:21 +0000 Subject: [PATCH 3/7] Use Newtonsoft.Json for Release deserialization and fix formatting Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- .../DemaConsulting.BuildMark.csproj | 1 + .../RepoConnectors/GitHubRepoConnector.cs | 2 +- .../RepoConnectors/GitHub/GitHubGraphQLClientTests.cs | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj b/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj index 1535928..a0f0b22 100644 --- a/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj +++ b/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj @@ -56,6 +56,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + all diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs index 65720e3..ba941bd 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs @@ -592,7 +592,7 @@ private static async Task> GetAllReleasesAsync( return releaseTagNames.Select(tagName => { var json = $$"""{"tag_name":"{{tagName}}"}"""; - return System.Text.Json.JsonSerializer.Deserialize(json) ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}"); + return Newtonsoft.Json.JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}"); }).ToList(); } diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs index a5bee84..48c280e 100644 --- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs +++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs @@ -353,7 +353,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\"")) { @@ -704,7 +704,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\"")) { @@ -1045,7 +1045,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\"")) { From ce91b03ca6383a5726987ecbb8ac1f0892fdc966 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 04:04:56 +0000 Subject: [PATCH 4/7] Fix JSON injection vulnerability by using JsonSerializer Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- .../RepoConnectors/GitHubRepoConnector.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs index ba941bd..ce71f3e 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs @@ -591,8 +591,11 @@ private static async Task> GetAllReleasesAsync( // This creates minimal Release objects with only TagName populated return releaseTagNames.Select(tagName => { - var json = $$"""{"tag_name":"{{tagName}}"}"""; - return Newtonsoft.Json.JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}"); + // Use JsonConvert with a proper object to avoid JSON injection + var releaseData = new { tag_name = tagName }; + var json = Newtonsoft.Json.JsonConvert.SerializeObject(releaseData); + var release = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + return release ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}"); }).ToList(); } From a1820c1307f64b755f53b4e837ac78ad894eb6af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:51:57 +0000 Subject: [PATCH 5/7] Replace Octokit Release type with custom Release record Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- .../DemaConsulting.BuildMark.csproj | 1 - .../RepoConnectors/GitHubRepoConnector.cs | 28 +++++++++---------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj b/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj index a0f0b22..1535928 100644 --- a/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj +++ b/src/DemaConsulting.BuildMark/DemaConsulting.BuildMark.csproj @@ -56,7 +56,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - all diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs index ce71f3e..dce0989 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs @@ -126,6 +126,12 @@ public override async Task GetBuildInformationAsync(Version? v internal sealed record Commit( string Sha); + /// + /// Simple release representation containing only the tag name. + /// + internal sealed record Release( + string TagName); + /// /// Container for GitHub data fetched from the API. /// @@ -214,7 +220,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 => branchTagNames.Contains(r.TagName)) .ToList(); // Build a mapping from tag name to tag object for quick lookup. @@ -223,12 +229,12 @@ internal static LookupData BuildLookupData(GitHubData data) // Build a mapping from tag name to release for version lookup. // This is used to match version objects back to their releases. - var tagToRelease = branchReleases.ToDictionary(r => r.TagName!, r => r); + var tagToRelease = branchReleases.ToDictionary(r => r.TagName, r => r); // Parse release tags into Version objects, maintaining release order (newest to oldest). // This is used to determine version history and find previous releases. var releaseVersions = branchReleases - .Select(r => Version.TryCreate(r.TagName!)) + .Select(r => Version.TryCreate(r.TagName)) .Where(v => v != null) .Cast() .ToList(); @@ -277,7 +283,7 @@ internal static (Version toVersion, string toHash) DetermineTargetVersion( // Use the most recent release (first in list since releases are newest to oldest) var latestRelease = lookupData.BranchReleases[0]; var latestReleaseVersion = lookupData.ReleaseVersions[0]; - var latestTagCommit = lookupData.TagsByName[latestRelease.TagName!]; + var latestTagCommit = lookupData.TagsByName[latestRelease.TagName]; // Check if current commit matches latest release tag if (latestTagCommit.Commit.Sha == toHash) @@ -319,7 +325,7 @@ internal static (Version? fromVersion, string? fromHash) DetermineBaselineVersio // Get commit hash for baseline version if one was found if (fromVersion != null && lookupData.TagToRelease.TryGetValue(fromVersion.Tag, out var fromRelease) && - lookupData.TagsByName.TryGetValue(fromRelease.TagName!, out var fromTagCommit)) + lookupData.TagsByName.TryGetValue(fromRelease.TagName, out var fromTagCommit)) { return (fromVersion, fromTagCommit.Commit.Sha); } @@ -587,16 +593,8 @@ private static async Task> GetAllReleasesAsync( // Fetch all release tag names for the repository using GraphQL var releaseTagNames = await graphqlClient.GetReleasesAsync(owner, repo); - // Convert tag names to Release objects using JSON deserialization - // This creates minimal Release objects with only TagName populated - return releaseTagNames.Select(tagName => - { - // Use JsonConvert with a proper object to avoid JSON injection - var releaseData = new { tag_name = tagName }; - var json = Newtonsoft.Json.JsonConvert.SerializeObject(releaseData); - var release = Newtonsoft.Json.JsonConvert.DeserializeObject(json); - return release ?? throw new InvalidOperationException($"Failed to create Release object for tag {tagName}"); - }).ToList(); + // Convert tag names to Release objects + return releaseTagNames.Select(tagName => new Release(tagName)).ToList(); } /// From e2a22ca4ca2e080ea668982503578d798f87a6ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:23:44 +0000 Subject: [PATCH 6/7] Return ReleaseNode directly from GetReleasesAsync to eliminate extra conversions Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- .../GitHub/GitHubGraphQLClient.cs | 17 +++-- .../RepoConnectors/GitHubRepoConnector.cs | 31 ++++----- .../GitHub/GitHubGraphQLClientTests.cs | 66 +++++++++---------- 3 files changed, 52 insertions(+), 62 deletions(-) diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs index 01e622e..f054866 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHub/GitHubGraphQLClient.cs @@ -177,14 +177,14 @@ ... on Commit { /// /// Repository owner. /// Repository name. - /// List of release tag names. - public async Task> GetReleasesAsync( + /// List of release nodes. + public async Task> GetReleasesAsync( string owner, string repo) { try { - var allReleaseTagNames = new List(); + var allReleaseNodes = new List(); string? afterCursor = null; bool hasNextPage; @@ -219,13 +219,12 @@ public async Task> GetReleasesAsync( // Execute GraphQL query var response = await _graphqlClient.SendQueryAsync(request); - // Extract release tag names from the GraphQL response, filtering out null or invalid values - var pageReleaseTagNames = response.Data?.Repository?.Releases?.Nodes? + // 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)) - .Select(n => n.TagName!) .ToList() ?? []; - allReleaseTagNames.AddRange(pageReleaseTagNames); + allReleaseNodes.AddRange(pageReleaseNodes); // Check if there are more pages var pageInfo = response.Data?.Repository?.Releases?.PageInfo; @@ -234,8 +233,8 @@ public async Task> GetReleasesAsync( } while (hasNextPage); - // Return list of all release tag names - return allReleaseTagNames; + // Return list of all release nodes + return allReleaseNodes; } catch { diff --git a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs index dce0989..ad26a2b 100644 --- a/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs +++ b/src/DemaConsulting.BuildMark/RepoConnectors/GitHubRepoConnector.cs @@ -126,18 +126,12 @@ public override async Task GetBuildInformationAsync(Version? v internal sealed record Commit( string Sha); - /// - /// Simple release representation containing only the tag name. - /// - internal sealed record Release( - string TagName); - /// /// Container for GitHub data fetched from the API. /// internal sealed record GitHubData( IReadOnlyList Commits, - IReadOnlyList Releases, + IReadOnlyList Releases, IReadOnlyList Tags, IReadOnlyList PullRequests, IReadOnlyList Issues); @@ -148,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); @@ -220,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 => 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. @@ -229,12 +223,12 @@ internal static LookupData BuildLookupData(GitHubData data) // Build a mapping from tag name to release for version lookup. // This is used to match version objects back to their releases. - var tagToRelease = branchReleases.ToDictionary(r => r.TagName, r => r); + var tagToRelease = branchReleases.ToDictionary(r => r.TagName!, r => r); // Parse release tags into Version objects, maintaining release order (newest to oldest). // This is used to determine version history and find previous releases. var releaseVersions = branchReleases - .Select(r => Version.TryCreate(r.TagName)) + .Select(r => Version.TryCreate(r.TagName!)) .Where(v => v != null) .Cast() .ToList(); @@ -283,7 +277,7 @@ internal static (Version toVersion, string toHash) DetermineTargetVersion( // Use the most recent release (first in list since releases are newest to oldest) var latestRelease = lookupData.BranchReleases[0]; var latestReleaseVersion = lookupData.ReleaseVersions[0]; - var latestTagCommit = lookupData.TagsByName[latestRelease.TagName]; + var latestTagCommit = lookupData.TagsByName[latestRelease.TagName!]; // Check if current commit matches latest release tag if (latestTagCommit.Commit.Sha == toHash) @@ -325,7 +319,7 @@ internal static (Version? fromVersion, string? fromHash) DetermineBaselineVersio // Get commit hash for baseline version if one was found if (fromVersion != null && lookupData.TagToRelease.TryGetValue(fromVersion.Tag, out var fromRelease) && - lookupData.TagsByName.TryGetValue(fromRelease.TagName, out var fromTagCommit)) + lookupData.TagsByName.TryGetValue(fromRelease.TagName!, out var fromTagCommit)) { return (fromVersion, fromTagCommit.Commit.Sha); } @@ -585,16 +579,13 @@ private static async Task> GetAllCommitsAsync( /// Repository owner. /// Repository name. /// List of all releases. - private static async Task> GetAllReleasesAsync( + private static async Task> GetAllReleasesAsync( GitHubGraphQLClient graphqlClient, string owner, string repo) { - // Fetch all release tag names for the repository using GraphQL - var releaseTagNames = await graphqlClient.GetReleasesAsync(owner, repo); - - // Convert tag names to Release objects - return releaseTagNames.Select(tagName => new Release(tagName)).ToList(); + // Fetch all releases for the repository using GraphQL + return await graphqlClient.GetReleasesAsync(owner, repo); } /// diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs index 48c280e..c4f0726 100644 --- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs +++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs @@ -820,14 +820,14 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_ValidResponse_ReturnsRele using var client = new GitHubGraphQLClient(httpClient); // Act - var releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + var releaseNodes = await client.GetReleasesAsync("owner", "repo"); // Assert - Assert.IsNotNull(releaseTagNames); - Assert.HasCount(3, releaseTagNames); - Assert.AreEqual("v1.0.0", releaseTagNames[0]); - Assert.AreEqual("v0.9.0", releaseTagNames[1]); - Assert.AreEqual("v0.8.5", releaseTagNames[2]); + 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); } /// @@ -855,11 +855,11 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_NoReleases_ReturnsEmptyLi using var client = new GitHubGraphQLClient(httpClient); // Act - var releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + var releaseNodes = await client.GetReleasesAsync("owner", "repo"); // Assert - Assert.IsNotNull(releaseTagNames); - Assert.IsEmpty(releaseTagNames); + Assert.IsNotNull(releaseNodes); + Assert.IsEmpty(releaseNodes); } /// @@ -879,11 +879,11 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_MissingData_ReturnsEmptyL using var client = new GitHubGraphQLClient(httpClient); // Act - var releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + var releaseNodes = await client.GetReleasesAsync("owner", "repo"); // Assert - Assert.IsNotNull(releaseTagNames); - Assert.IsEmpty(releaseTagNames); + Assert.IsNotNull(releaseNodes); + Assert.IsEmpty(releaseNodes); } /// @@ -899,11 +899,11 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_HttpError_ReturnsEmptyLis using var client = new GitHubGraphQLClient(httpClient); // Act - var releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + var releaseNodes = await client.GetReleasesAsync("owner", "repo"); // Assert - Assert.IsNotNull(releaseTagNames); - Assert.IsEmpty(releaseTagNames); + Assert.IsNotNull(releaseNodes); + Assert.IsEmpty(releaseNodes); } /// @@ -919,11 +919,11 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_InvalidJson_ReturnsEmptyL using var client = new GitHubGraphQLClient(httpClient); // Act - var releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + var releaseNodes = await client.GetReleasesAsync("owner", "repo"); // Assert - Assert.IsNotNull(releaseTagNames); - Assert.IsEmpty(releaseTagNames); + Assert.IsNotNull(releaseNodes); + Assert.IsEmpty(releaseNodes); } /// @@ -953,12 +953,12 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_SingleRelease_ReturnsOneT using var client = new GitHubGraphQLClient(httpClient); // Act - var releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + var releaseNodes = await client.GetReleasesAsync("owner", "repo"); // Assert - Assert.IsNotNull(releaseTagNames); - Assert.HasCount(1, releaseTagNames); - Assert.AreEqual("v2.0.0-beta1", releaseTagNames[0]); + Assert.IsNotNull(releaseNodes); + Assert.HasCount(1, releaseNodes); + Assert.AreEqual("v2.0.0-beta1", releaseNodes[0].TagName); } /// @@ -990,13 +990,13 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_MissingTagNameProperty_Sk using var client = new GitHubGraphQLClient(httpClient); // Act - var releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + var releaseNodes = await client.GetReleasesAsync("owner", "repo"); // Assert - Assert.IsNotNull(releaseTagNames); - Assert.HasCount(2, releaseTagNames); - Assert.AreEqual("v1.0.0", releaseTagNames[0]); - Assert.AreEqual("v0.9.0", releaseTagNames[1]); + Assert.IsNotNull(releaseNodes); + Assert.HasCount(2, releaseNodes); + Assert.AreEqual("v1.0.0", releaseNodes[0].TagName); + Assert.AreEqual("v0.9.0", releaseNodes[1].TagName); } /// @@ -1011,14 +1011,14 @@ public async Task GitHubGraphQLClient_GetReleasesAsync_WithPagination_ReturnsAll using var client = new GitHubGraphQLClient(httpClient); // Act - var releaseTagNames = await client.GetReleasesAsync("owner", "repo"); + var releaseNodes = await client.GetReleasesAsync("owner", "repo"); // Assert - Assert.IsNotNull(releaseTagNames); - Assert.HasCount(3, releaseTagNames); - Assert.AreEqual("v3.0.0", releaseTagNames[0]); - Assert.AreEqual("v2.0.0", releaseTagNames[1]); - Assert.AreEqual("v1.0.0", releaseTagNames[2]); + 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); } /// From aaafacb0fee72a2832808aeaeb5867f965116175 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:33:58 +0000 Subject: [PATCH 7/7] Split GitHubGraphQLClientTests into separate files by functionality Co-authored-by: Malcolmnixon <1863707+Malcolmnixon@users.noreply.github.com> --- .../GitHubGraphQLClientFindIssueIdsTests.cs | 436 +++++++ .../GitHubGraphQLClientGetCommitsTests.cs | 449 +++++++ .../GitHubGraphQLClientGetReleasesTests.cs | 421 +++++++ .../GitHub/GitHubGraphQLClientTests.cs | 1122 ----------------- 4 files changed, 1306 insertions(+), 1122 deletions(-) create mode 100644 test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientFindIssueIdsTests.cs create mode 100644 test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetCommitsTests.cs create mode 100644 test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetReleasesTests.cs delete mode 100644 test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs 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/GitHubGraphQLClientGetCommitsTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetCommitsTests.cs new file mode 100644 index 0000000..da3d0bd --- /dev/null +++ b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientGetCommitsTests.cs @@ -0,0 +1,449 @@ +// 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 GetCommitsAsync method. +/// +[TestClass] +public class GitHubGraphQLClientGetCommitsTests +{ + /// + /// Test that GetCommitsAsync returns expected commit SHAs with valid response. + /// + [TestMethod] + public async Task GitHubGraphQLClient_GetCommitsAsync_ValidResponse_ReturnsCommitShas() + { + // Arrange + var mockResponse = @"{ + ""data"": { + ""repository"": { + ""ref"": { + ""target"": { + ""history"": { + ""nodes"": [ + { ""oid"": ""abc123"" }, + { ""oid"": ""def456"" }, + { ""oid"": ""ghi789"" } + ], + ""pageInfo"": { + ""hasNextPage"": false, + ""endCursor"": null + } + } + } + } + } + } + }"; + + using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK); + using var client = new GitHubGraphQLClient(httpClient); + + // Act + var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); + + // Assert + Assert.IsNotNull(commitShas); + Assert.HasCount(3, commitShas); + Assert.AreEqual("abc123", commitShas[0]); + Assert.AreEqual("def456", commitShas[1]); + Assert.AreEqual("ghi789", commitShas[2]); + } + + /// + /// Test that GetCommitsAsync returns empty list when no commits are found. + /// + [TestMethod] + public async Task GitHubGraphQLClient_GetCommitsAsync_NoCommits_ReturnsEmptyList() + { + // Arrange + var mockResponse = @"{ + ""data"": { + ""repository"": { + ""ref"": { + ""target"": { + ""history"": { + ""nodes"": [], + ""pageInfo"": { + ""hasNextPage"": false, + ""endCursor"": null + } + } + } + } + } + } + }"; + + using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK); + using var client = new GitHubGraphQLClient(httpClient); + + // Act + var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); + + // Assert + Assert.IsNotNull(commitShas); + Assert.IsEmpty(commitShas); + } + + /// + /// Test that GetCommitsAsync returns empty list when response has missing data. + /// + [TestMethod] + public async Task GitHubGraphQLClient_GetCommitsAsync_MissingData_ReturnsEmptyList() + { + // Arrange + var mockResponse = @"{ + ""data"": { + ""repository"": null + } + }"; + + using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK); + using var client = new GitHubGraphQLClient(httpClient); + + // Act + var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); + + // Assert + Assert.IsNotNull(commitShas); + Assert.IsEmpty(commitShas); + } + + /// + /// Test that GetCommitsAsync returns empty list on HTTP error. + /// + [TestMethod] + public async Task GitHubGraphQLClient_GetCommitsAsync_HttpError_ReturnsEmptyList() + { + // Arrange + var mockResponse = @"{ ""message"": ""Not Found"" }"; + + using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound); + using var client = new GitHubGraphQLClient(httpClient); + + // Act + var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); + + // Assert + Assert.IsNotNull(commitShas); + Assert.IsEmpty(commitShas); + } + + /// + /// Test that GetCommitsAsync returns empty list on invalid JSON. + /// + [TestMethod] + public async Task GitHubGraphQLClient_GetCommitsAsync_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 commitShas = await client.GetCommitsAsync("owner", "repo", "main"); + + // Assert + Assert.IsNotNull(commitShas); + Assert.IsEmpty(commitShas); + } + + /// + /// Test that GetCommitsAsync returns single commit SHA correctly. + /// + [TestMethod] + public async Task GitHubGraphQLClient_GetCommitsAsync_SingleCommit_ReturnsOneCommitSha() + { + // Arrange + var mockResponse = @"{ + ""data"": { + ""repository"": { + ""ref"": { + ""target"": { + ""history"": { + ""nodes"": [ + { ""oid"": ""abc123def456"" } + ], + ""pageInfo"": { + ""hasNextPage"": false, + ""endCursor"": null + } + } + } + } + } + } + }"; + + using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK); + using var client = new GitHubGraphQLClient(httpClient); + + // Act + var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); + + // Assert + Assert.IsNotNull(commitShas); + Assert.HasCount(1, commitShas); + Assert.AreEqual("abc123def456", commitShas[0]); + } + + /// + /// Test that GetCommitsAsync handles nodes with missing oid property. + /// + [TestMethod] + public async Task GitHubGraphQLClient_GetCommitsAsync_MissingOidProperty_SkipsInvalidNodes() + { + // Arrange + var mockResponse = @"{ + ""data"": { + ""repository"": { + ""ref"": { + ""target"": { + ""history"": { + ""nodes"": [ + { ""oid"": ""commit1"" }, + { ""author"": ""missing oid"" }, + { ""oid"": ""commit2"" } + ], + ""pageInfo"": { + ""hasNextPage"": false, + ""endCursor"": null + } + } + } + } + } + } + }"; + + using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK); + using var client = new GitHubGraphQLClient(httpClient); + + // Act + var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); + + // Assert + Assert.IsNotNull(commitShas); + Assert.HasCount(2, commitShas); + Assert.AreEqual("commit1", commitShas[0]); + Assert.AreEqual("commit2", commitShas[1]); + } + + /// + /// Test that GetCommitsAsync handles pagination correctly. + /// + [TestMethod] + public async Task GitHubGraphQLClient_GetCommitsAsync_WithPagination_ReturnsAllCommits() + { + // Arrange - Create mock handler that returns different responses for different pages + var mockHandler = new CommitPaginationMockHttpMessageHandler(); + using var httpClient = new HttpClient(mockHandler); + using var client = new GitHubGraphQLClient(httpClient); + + // Act + var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); + + // Assert + Assert.IsNotNull(commitShas); + Assert.HasCount(3, commitShas); + Assert.AreEqual("page1commit", commitShas[0]); + Assert.AreEqual("page2commit", commitShas[1]); + Assert.AreEqual("page3commit", commitShas[2]); + } + + /// + /// Mock HTTP message handler for testing commit pagination. + /// + private sealed class CommitPaginationMockHttpMessageHandler : 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"": { + ""ref"": { + ""target"": { + ""history"": { + ""nodes"": [ + { ""oid"": ""page1commit"" } + ], + ""pageInfo"": { + ""hasNextPage"": true, + ""endCursor"": ""cursor1"" + } + } + } + } + } + } + }"; + } + else if (requestBody.Contains("\"cursor1\"")) + { + // Second page + responseContent = @"{ + ""data"": { + ""repository"": { + ""ref"": { + ""target"": { + ""history"": { + ""nodes"": [ + { ""oid"": ""page2commit"" } + ], + ""pageInfo"": { + ""hasNextPage"": true, + ""endCursor"": ""cursor2"" + } + } + } + } + } + } + }"; + } + else + { + // Third (last) page + responseContent = @"{ + ""data"": { + ""repository"": { + ""ref"": { + ""target"": { + ""history"": { + ""nodes"": [ + { ""oid"": ""page3commit"" } + ], + ""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); + } + } +} 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); + } + } +} diff --git a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs b/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs deleted file mode 100644 index c4f0726..0000000 --- a/test/DemaConsulting.BuildMark.Tests/RepoConnectors/GitHub/GitHubGraphQLClientTests.cs +++ /dev/null @@ -1,1122 +0,0 @@ -// 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 class. -/// -[TestClass] -public class GitHubGraphQLClientTests -{ - /// - /// 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. - /// - [TestMethod] - public async Task GitHubGraphQLClient_GetCommitsAsync_ValidResponse_ReturnsCommitShas() - { - // Arrange - var mockResponse = @"{ - ""data"": { - ""repository"": { - ""ref"": { - ""target"": { - ""history"": { - ""nodes"": [ - { ""oid"": ""abc123"" }, - { ""oid"": ""def456"" }, - { ""oid"": ""ghi789"" } - ], - ""pageInfo"": { - ""hasNextPage"": false, - ""endCursor"": null - } - } - } - } - } - } - }"; - - using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK); - using var client = new GitHubGraphQLClient(httpClient); - - // Act - var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); - - // Assert - Assert.IsNotNull(commitShas); - Assert.HasCount(3, commitShas); - Assert.AreEqual("abc123", commitShas[0]); - Assert.AreEqual("def456", commitShas[1]); - Assert.AreEqual("ghi789", commitShas[2]); - } - - /// - /// Test that GetCommitsAsync returns empty list when no commits are found. - /// - [TestMethod] - public async Task GitHubGraphQLClient_GetCommitsAsync_NoCommits_ReturnsEmptyList() - { - // Arrange - var mockResponse = @"{ - ""data"": { - ""repository"": { - ""ref"": { - ""target"": { - ""history"": { - ""nodes"": [], - ""pageInfo"": { - ""hasNextPage"": false, - ""endCursor"": null - } - } - } - } - } - } - }"; - - using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK); - using var client = new GitHubGraphQLClient(httpClient); - - // Act - var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); - - // Assert - Assert.IsNotNull(commitShas); - Assert.IsEmpty(commitShas); - } - - /// - /// Test that GetCommitsAsync returns empty list when response has missing data. - /// - [TestMethod] - public async Task GitHubGraphQLClient_GetCommitsAsync_MissingData_ReturnsEmptyList() - { - // Arrange - var mockResponse = @"{ - ""data"": { - ""repository"": null - } - }"; - - using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK); - using var client = new GitHubGraphQLClient(httpClient); - - // Act - var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); - - // Assert - Assert.IsNotNull(commitShas); - Assert.IsEmpty(commitShas); - } - - /// - /// Test that GetCommitsAsync returns empty list on HTTP error. - /// - [TestMethod] - public async Task GitHubGraphQLClient_GetCommitsAsync_HttpError_ReturnsEmptyList() - { - // Arrange - var mockResponse = @"{ ""message"": ""Not Found"" }"; - - using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.NotFound); - using var client = new GitHubGraphQLClient(httpClient); - - // Act - var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); - - // Assert - Assert.IsNotNull(commitShas); - Assert.IsEmpty(commitShas); - } - - /// - /// Test that GetCommitsAsync returns empty list on invalid JSON. - /// - [TestMethod] - public async Task GitHubGraphQLClient_GetCommitsAsync_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 commitShas = await client.GetCommitsAsync("owner", "repo", "main"); - - // Assert - Assert.IsNotNull(commitShas); - Assert.IsEmpty(commitShas); - } - - /// - /// Test that GetCommitsAsync returns single commit SHA correctly. - /// - [TestMethod] - public async Task GitHubGraphQLClient_GetCommitsAsync_SingleCommit_ReturnsOneCommitSha() - { - // Arrange - var mockResponse = @"{ - ""data"": { - ""repository"": { - ""ref"": { - ""target"": { - ""history"": { - ""nodes"": [ - { ""oid"": ""abc123def456"" } - ], - ""pageInfo"": { - ""hasNextPage"": false, - ""endCursor"": null - } - } - } - } - } - } - }"; - - using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK); - using var client = new GitHubGraphQLClient(httpClient); - - // Act - var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); - - // Assert - Assert.IsNotNull(commitShas); - Assert.HasCount(1, commitShas); - Assert.AreEqual("abc123def456", commitShas[0]); - } - - /// - /// Test that GetCommitsAsync handles nodes with missing oid property. - /// - [TestMethod] - public async Task GitHubGraphQLClient_GetCommitsAsync_MissingOidProperty_SkipsInvalidNodes() - { - // Arrange - var mockResponse = @"{ - ""data"": { - ""repository"": { - ""ref"": { - ""target"": { - ""history"": { - ""nodes"": [ - { ""oid"": ""commit1"" }, - { ""author"": ""missing oid"" }, - { ""oid"": ""commit2"" } - ], - ""pageInfo"": { - ""hasNextPage"": false, - ""endCursor"": null - } - } - } - } - } - } - }"; - - using var httpClient = CreateMockHttpClient(mockResponse, HttpStatusCode.OK); - using var client = new GitHubGraphQLClient(httpClient); - - // Act - var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); - - // Assert - Assert.IsNotNull(commitShas); - Assert.HasCount(2, commitShas); - Assert.AreEqual("commit1", commitShas[0]); - Assert.AreEqual("commit2", commitShas[1]); - } - - /// - /// Test that GetCommitsAsync handles pagination correctly. - /// - [TestMethod] - public async Task GitHubGraphQLClient_GetCommitsAsync_WithPagination_ReturnsAllCommits() - { - // Arrange - Create mock handler that returns different responses for different pages - var mockHandler = new CommitPaginationMockHttpMessageHandler(); - using var httpClient = new HttpClient(mockHandler); - using var client = new GitHubGraphQLClient(httpClient); - - // Act - var commitShas = await client.GetCommitsAsync("owner", "repo", "main"); - - // Assert - Assert.IsNotNull(commitShas); - Assert.HasCount(3, commitShas); - Assert.AreEqual("page1commit", commitShas[0]); - Assert.AreEqual("page2commit", commitShas[1]); - Assert.AreEqual("page3commit", commitShas[2]); - } - - /// - /// Mock HTTP message handler for testing commit pagination. - /// - private sealed class CommitPaginationMockHttpMessageHandler : 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"": { - ""ref"": { - ""target"": { - ""history"": { - ""nodes"": [ - { ""oid"": ""page1commit"" } - ], - ""pageInfo"": { - ""hasNextPage"": true, - ""endCursor"": ""cursor1"" - } - } - } - } - } - } - }"; - } - else if (requestBody.Contains("\"cursor1\"")) - { - // Second page - responseContent = @"{ - ""data"": { - ""repository"": { - ""ref"": { - ""target"": { - ""history"": { - ""nodes"": [ - { ""oid"": ""page2commit"" } - ], - ""pageInfo"": { - ""hasNextPage"": true, - ""endCursor"": ""cursor2"" - } - } - } - } - } - } - }"; - } - else - { - // Third (last) page - responseContent = @"{ - ""data"": { - ""repository"": { - ""ref"": { - ""target"": { - ""history"": { - ""nodes"": [ - { ""oid"": ""page3commit"" } - ], - ""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 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; - } - } -}