Skip to content

Commit dfd7dba

Browse files
committed
feat(testing): add GitHub client factory and comprehensive testing infrastructure
- Implement IGitHubClientFactory pattern for testable GitHub client creation - Add 33 integration tests using WireMock.Net for HTTP-level GitHub API mocking - Add 11 contract tests using NJsonSchema and Verify.NET for API validation - Refactor GitHubService to use factory pattern for improved testability - Update all existing unit tests to use factory pattern - Add comprehensive testing documentation New Components: - IGitHubClientFactory and GitHubClientFactory for DI-friendly client creation - 10xGitHubPolicies.Tests.Integration project (33 tests, 100% pass rate) - 10xGitHubPolicies.Tests.Contracts project (11 tests) - GitHubAppOptions.BaseUrl for test environment configuration New Documentation: - docs/github-client-factory.md - Factory pattern implementation guide - docs/testing-integration-tests.md - Integration testing guide - docs/testing-contract-tests.md - Contract testing guide Test Coverage: - Integration: File operations, repositories, issues, workflow permissions, rate limiting, token caching, team membership - Contract: Schema validation and snapshot testing for GitHub API responses This completes the multi-level testing strategy for GitHub API interactions, ensuring reliable integration and early detection of API breaking changes.
1 parent b31c7fa commit dfd7dba

File tree

54 files changed

+6216
-117
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+6216
-117
lines changed

.cursor/plans/githubservice-integration-contract-f95b8e0c.plan.md

Lines changed: 1191 additions & 0 deletions
Large diffs are not rendered by default.

10xGitHubPolicies.App/Options/GitHubAppOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,10 @@ public class GitHubAppOptions
88
public string PrivateKey { get; set; } = string.Empty;
99
public long InstallationId { get; set; }
1010
public string OrganizationName { get; set; } = string.Empty;
11+
12+
/// <summary>
13+
/// Optional base URL for GitHub API. If null, uses default GitHub API (https://api.github.com).
14+
/// Primarily used for testing with WireMock.
15+
/// </summary>
16+
public string? BaseUrl { get; set; }
1117
}

10xGitHubPolicies.App/Program.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@
8787

8888

8989
builder.Services.AddMemoryCache();
90+
91+
// Register GitHub client factory with optional base URL from configuration
92+
builder.Services.AddSingleton<IGitHubClientFactory>(sp =>
93+
{
94+
var options = sp.GetRequiredService<IOptions<GitHubAppOptions>>();
95+
return new GitHubClientFactory(options.Value.BaseUrl);
96+
});
97+
9098
builder.Services.AddSingleton<IGitHubService, GitHubService>();
9199
builder.Services.AddSingleton<IConfigurationService, ConfigurationService>();
92100
builder.Services.AddScoped<IScanningService, ScanningService>();
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using Octokit;
2+
using Octokit.Internal;
3+
4+
namespace _10xGitHubPolicies.App.Services.GitHub;
5+
6+
/// <summary>
7+
/// Default implementation of IGitHubClientFactory.
8+
/// Creates GitHubClient instances with support for custom base URLs (primarily for testing).
9+
/// </summary>
10+
public class GitHubClientFactory : IGitHubClientFactory
11+
{
12+
private readonly string? _baseUrl;
13+
14+
/// <summary>
15+
/// Initializes a new instance of the GitHubClientFactory.
16+
/// </summary>
17+
/// <param name="baseUrl">Optional custom base URL for GitHub API. If null, uses default GitHub API URL.</param>
18+
public GitHubClientFactory(string? baseUrl = null)
19+
{
20+
_baseUrl = baseUrl;
21+
}
22+
23+
/// <inheritdoc />
24+
public GitHubClient CreateClient(string token)
25+
{
26+
var productHeader = new ProductHeaderValue("10xGitHubPolicies");
27+
var credentials = new Credentials(token);
28+
var credentialStore = new InMemoryCredentialStore(credentials);
29+
30+
if (_baseUrl != null)
31+
{
32+
return new GitHubClient(productHeader, credentialStore, new Uri(_baseUrl));
33+
}
34+
35+
return new GitHubClient(productHeader, credentialStore);
36+
}
37+
38+
/// <inheritdoc />
39+
public GitHubClient CreateAppClient(string jwt)
40+
{
41+
var productHeader = new ProductHeaderValue("10xGitHubPolicies");
42+
var credentials = new Credentials(jwt, AuthenticationType.Bearer);
43+
var credentialStore = new InMemoryCredentialStore(credentials);
44+
45+
if (_baseUrl != null)
46+
{
47+
return new GitHubClient(productHeader, credentialStore, new Uri(_baseUrl));
48+
}
49+
50+
return new GitHubClient(productHeader, credentialStore);
51+
}
52+
}
53+

10xGitHubPolicies.App/Services/GitHub/GitHubService.cs

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,19 @@ public class GitHubService : IGitHubService
2222
private readonly GitHubAppOptions _options;
2323
private readonly ILogger<GitHubService> _logger;
2424
private readonly IMemoryCache _cache;
25+
private readonly IGitHubClientFactory _clientFactory;
2526
private const string InstallationTokenCacheKey = "GitHubInstallationToken";
2627

27-
public GitHubService(IOptions<GitHubAppOptions> options, ILogger<GitHubService> logger, IMemoryCache cache)
28+
public GitHubService(
29+
IOptions<GitHubAppOptions> options,
30+
ILogger<GitHubService> logger,
31+
IMemoryCache cache,
32+
IGitHubClientFactory? clientFactory = null)
2833
{
2934
_options = options.Value;
3035
_logger = logger;
3136
_cache = cache;
37+
_clientFactory = clientFactory ?? new GitHubClientFactory(_options.BaseUrl);
3238
}
3339

3440
public async Task ArchiveRepositoryAsync(long repositoryId)
@@ -76,10 +82,7 @@ public async Task<Repository> GetRepositorySettingsAsync(long repositoryId)
7682

7783
public async Task<bool> IsUserMemberOfTeamAsync(string userAccessToken, string org, string teamSlug)
7884
{
79-
var userClient = new GitHubClient(new ProductHeaderValue("10xGitHubPolicies"))
80-
{
81-
Credentials = new Credentials(userAccessToken)
82-
};
85+
var userClient = _clientFactory.CreateClient(userAccessToken);
8386

8487
try
8588
{
@@ -170,10 +173,7 @@ public async Task<IReadOnlyList<Issue>> GetOpenIssuesAsync(long repositoryId, st
170173

171174
public async Task<IReadOnlyList<Organization>> GetUserOrganizationsAsync(string userAccessToken)
172175
{
173-
var userClient = new GitHubClient(new ProductHeaderValue("10xGitHubPolicies"))
174-
{
175-
Credentials = new Credentials(userAccessToken)
176-
};
176+
var userClient = _clientFactory.CreateClient(userAccessToken);
177177

178178
try
179179
{
@@ -188,10 +188,7 @@ public async Task<IReadOnlyList<Organization>> GetUserOrganizationsAsync(string
188188

189189
public async Task<IReadOnlyList<Team>> GetOrganizationTeamsAsync(string userAccessToken, string org)
190190
{
191-
var userClient = new GitHubClient(new ProductHeaderValue("10xGitHubPolicies"))
192-
{
193-
Credentials = new Credentials(userAccessToken)
194-
};
191+
var userClient = _clientFactory.CreateClient(userAccessToken);
195192

196193
try
197194
{
@@ -211,7 +208,7 @@ private async Task<GitHubClient> GetAuthenticatedClient()
211208
_logger.LogInformation("Installation token not found in cache. Generating a new one.");
212209

213210
var jwt = GetJwt();
214-
var appClient = new GitHubClient(new ProductHeaderValue("10xGitHubPolicies"), new InMemoryCredentialStore(new Credentials(jwt, AuthenticationType.Bearer)));
211+
var appClient = _clientFactory.CreateAppClient(jwt);
215212

216213
var tokenResponse = await appClient.GitHubApps.CreateInstallationToken(_options.InstallationId);
217214

@@ -222,7 +219,7 @@ private async Task<GitHubClient> GetAuthenticatedClient()
222219
return tokenResponse.Token;
223220
});
224221

225-
return new GitHubClient(new ProductHeaderValue("10xGitHubPolicies"), new InMemoryCredentialStore(new Credentials(token)));
222+
return _clientFactory.CreateClient(token);
226223
}
227224

228225
private string GetJwt()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using Octokit;
2+
3+
namespace _10xGitHubPolicies.App.Services.GitHub;
4+
5+
/// <summary>
6+
/// Factory for creating GitHubClient instances with optional custom base URLs.
7+
/// Supports both user token authentication and GitHub App JWT authentication.
8+
/// </summary>
9+
public interface IGitHubClientFactory
10+
{
11+
/// <summary>
12+
/// Creates a GitHubClient authenticated with a user or installation access token.
13+
/// </summary>
14+
/// <param name="token">The access token for authentication</param>
15+
/// <returns>A configured GitHubClient instance</returns>
16+
GitHubClient CreateClient(string token);
17+
18+
/// <summary>
19+
/// Creates a GitHubClient authenticated with a GitHub App JWT token.
20+
/// </summary>
21+
/// <param name="jwt">The JWT token for GitHub App authentication</param>
22+
/// <returns>A configured GitHubClient instance for app-level operations</returns>
23+
GitHubClient CreateAppClient(string jwt);
24+
}
25+
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<RootNamespace>_10xGitHubPolicies.Tests.Contracts</RootNamespace>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<IsPackable>false</IsPackable>
9+
<IsTestProject>true</IsTestProject>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<!-- Core Testing -->
14+
<PackageReference Include="xunit" Version="2.9.2" />
15+
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
16+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
17+
<PackageReference Include="FluentAssertions" Version="6.12.0" />
18+
19+
<!-- Contract Testing -->
20+
<PackageReference Include="NJsonSchema" Version="11.0.2" />
21+
<PackageReference Include="Verify.Xunit" Version="28.5.1" />
22+
<PackageReference Include="WireMock.Net" Version="1.5.59" />
23+
24+
<!-- Dependencies -->
25+
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
26+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
27+
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.10" />
28+
<PackageReference Include="NSubstitute" Version="5.1.0" />
29+
<PackageReference Include="Bogus" Version="35.4.0" />
30+
31+
<!-- Code Coverage -->
32+
<PackageReference Include="coverlet.collector" Version="6.0.0" />
33+
</ItemGroup>
34+
35+
<ItemGroup>
36+
<Using Include="Xunit" />
37+
</ItemGroup>
38+
39+
<ItemGroup>
40+
<ProjectReference Include="..\10xGitHubPolicies.App\10xGitHubPolicies.App.csproj" />
41+
</ItemGroup>
42+
43+
<ItemGroup>
44+
<None Update="Schemas\*.json">
45+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
46+
</None>
47+
</ItemGroup>
48+
49+
</Project>
50+

0 commit comments

Comments
 (0)