-
Notifications
You must be signed in to change notification settings - Fork 3
test: Shared test infrastructure — MockHttpMessageHandler, fixtures & helpers (#51) #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ebbbaa8
272c623
18c0c52
c8f21d2
c52bd19
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| name: CI - Unit Tests | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - master | ||
| - 'feature/**' | ||
| - 'fix/**' | ||
| pull_request: | ||
| branches: | ||
| - master | ||
|
|
||
| jobs: | ||
| unit-tests: | ||
| name: Unit Tests (.NET 8) | ||
| runs-on: ubuntu-latest | ||
|
|
||
| permissions: | ||
| contents: read # checkout | ||
| checks: write # dorny/test-reporter — create check runs | ||
| pull-requests: write # dorny/test-reporter — annotate PRs | ||
|
|
||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Setup .NET 8 | ||
| uses: actions/setup-dotnet@v4 | ||
| with: | ||
| dotnet-version: '8.0.x' | ||
|
|
||
| - name: Restore dependencies | ||
| run: dotnet restore PayBridge.SDK.sln | ||
|
|
||
| - name: Build solution | ||
| run: dotnet build PayBridge.SDK.sln --configuration Release --no-restore | ||
|
|
||
| - name: Run unit tests | ||
| run: | | ||
| dotnet test PayBridge.SDK.Test/PayBridge.SDK.Test.csproj \ | ||
| --configuration Release \ | ||
| --no-build \ | ||
| --filter "Category=Unit" \ | ||
| --logger "trx;LogFileName=unit-test-results.trx" \ | ||
| --results-directory ./TestResults \ | ||
| --collect:"XPlat Code Coverage" | ||
|
|
||
| - name: Upload test results | ||
| uses: actions/upload-artifact@v4 | ||
| if: always() | ||
| with: | ||
| name: unit-test-results | ||
| path: ./TestResults/*.trx | ||
|
|
||
| - name: Upload coverage report | ||
| uses: actions/upload-artifact@v4 | ||
| if: always() | ||
| with: | ||
| name: coverage-report | ||
| path: ./TestResults/**/coverage.cobertura.xml | ||
|
|
||
| - name: Publish test results (PR comment) | ||
| uses: dorny/test-reporter@v1 | ||
| if: always() | ||
| with: | ||
| name: Unit Test Results | ||
| path: ./TestResults/*.trx | ||
| reporter: dotnet-trx | ||
| fail-on-error: true | ||
| fail-on-empty: false | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| name: Integration Tests | ||
|
|
||
| on: | ||
| # Run manually from the Actions tab | ||
| workflow_dispatch: | ||
| inputs: | ||
| gateway: | ||
| description: 'Gateway to test (e.g. paystack, flutterwave, all)' | ||
| required: false | ||
| default: 'all' | ||
|
|
||
| # Also run nightly on master so regressions are caught early | ||
| schedule: | ||
| - cron: '0 2 * * *' # 02:00 UTC every day | ||
|
|
||
| jobs: | ||
| integration-tests: | ||
| name: Integration Tests (.NET 8) | ||
| runs-on: ubuntu-latest | ||
| environment: integration # Create this environment in repo Settings > Environments | ||
|
|
||
| permissions: | ||
| contents: read | ||
| checks: write | ||
| pull-requests: write | ||
|
|
||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Setup .NET 8 | ||
| uses: actions/setup-dotnet@v4 | ||
| with: | ||
| dotnet-version: '8.0.x' | ||
|
|
||
| - name: Restore dependencies | ||
| run: dotnet restore PayBridge.SDK.sln | ||
|
|
||
| - name: Build solution | ||
| run: dotnet build PayBridge.SDK.sln --configuration Release --no-restore | ||
|
|
||
| - name: Run integration tests | ||
| env: | ||
| # ── Nigerian gateways ──────────────────────────────────────────────── | ||
| PAYSTACK_SECRET_KEY: ${{ secrets.PAYSTACK_SECRET_KEY }} | ||
| FLUTTERWAVE_SECRET_KEY: ${{ secrets.FLUTTERWAVE_SECRET_KEY }} | ||
| MONNIFY_API_KEY: ${{ secrets.MONNIFY_API_KEY }} | ||
| MONNIFY_SECRET_KEY: ${{ secrets.MONNIFY_SECRET_KEY }} | ||
| MONNIFY_CONTRACT_CODE: ${{ secrets.MONNIFY_CONTRACT_CODE }} | ||
| SQUAD_SECRET_KEY: ${{ secrets.SQUAD_SECRET_KEY }} | ||
| SQUAD_IS_SANDBOX: 'true' | ||
| KORAPAY_SECRET_KEY: ${{ secrets.KORAPAY_SECRET_KEY }} | ||
| INTERSWITCH_CLIENT_ID: ${{ secrets.INTERSWITCH_CLIENT_ID }} | ||
| INTERSWITCH_CLIENT_SECRET: ${{ secrets.INTERSWITCH_CLIENT_SECRET }} | ||
| REMITA_MERCHANT_ID: ${{ secrets.REMITA_MERCHANT_ID }} | ||
| REMITA_API_KEY: ${{ secrets.REMITA_API_KEY }} | ||
| REMITA_SERVICE_TYPE_ID: ${{ secrets.REMITA_SERVICE_TYPE_ID }} | ||
| OPAY_MERCHANT_ID: ${{ secrets.OPAY_MERCHANT_ID }} | ||
| OPAY_PUBLIC_KEY: ${{ secrets.OPAY_PUBLIC_KEY }} | ||
| OPAY_PRIVATE_KEY: ${{ secrets.OPAY_PRIVATE_KEY }} | ||
| # ── African / global gateways ──────────────────────────────────────── | ||
| DPO_COMPANY_TOKEN: ${{ secrets.DPO_COMPANY_TOKEN }} | ||
| DPO_SERVICE_TYPE: ${{ secrets.DPO_SERVICE_TYPE }} | ||
| PAWAPAY_API_TOKEN: ${{ secrets.PAWAPAY_API_TOKEN }} | ||
| PEACH_ENTITY_ID: ${{ secrets.PEACH_ENTITY_ID }} | ||
| PEACH_ACCESS_TOKEN: ${{ secrets.PEACH_ACCESS_TOKEN }} | ||
| # ── Global gateways ────────────────────────────────────────────────── | ||
| STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} | ||
| CHECKOUT_SECRET_KEY: ${{ secrets.CHECKOUT_SECRET_KEY }} | ||
| BENEFITPAY_MERCHANT_ID: ${{ secrets.BENEFITPAY_MERCHANT_ID }} | ||
| BENEFITPAY_API_KEY: ${{ secrets.BENEFITPAY_API_KEY }} | ||
| KNET_TRANSPORT_ID: ${{ secrets.KNET_TRANSPORT_ID }} | ||
| KNET_PASSWORD: ${{ secrets.KNET_PASSWORD }} | ||
| run: | | ||
| dotnet test PayBridge.SDK.Test/PayBridge.SDK.Test.csproj \ | ||
| --configuration Release \ | ||
| --no-build \ | ||
| --filter "Category=Integration" \ | ||
| --logger "trx;LogFileName=integration-test-results.trx" \ | ||
| --results-directory ./TestResults | ||
|
|
||
| - name: Upload integration test results | ||
| uses: actions/upload-artifact@v4 | ||
| if: always() | ||
| with: | ||
| name: integration-test-results | ||
| path: ./TestResults/*.trx | ||
|
|
||
| - name: Publish test results | ||
| uses: dorny/test-reporter@v1 | ||
| if: always() | ||
| with: | ||
| name: Integration Test Results | ||
| path: ./TestResults/*.trx | ||
| reporter: dotnet-trx | ||
| fail-on-error: false # don't fail the workflow if sandbox keys are missing |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| using Xunit; | ||
|
|
||
| namespace PayBridge.SDK.Test.Helpers; | ||
|
|
||
| /// <summary> | ||
| /// Base class for integration tests. | ||
| /// Any test class that derives from this will automatically be skipped | ||
| /// when required environment variables are missing, keeping CI output clean. | ||
| /// | ||
| /// Usage: | ||
| /// <code> | ||
| /// public class PaystackIntegrationTests : IntegrationTestBase | ||
| /// { | ||
| /// public PaystackIntegrationTests() | ||
| /// : base("PAYSTACK_SECRET_KEY") { } | ||
| /// } | ||
| /// </code> | ||
| /// </summary> | ||
| public abstract class IntegrationTestBase | ||
| { | ||
| private readonly string[] _requiredVars; | ||
|
|
||
| /// <summary> | ||
| /// The reason string reported by xUnit when a test is skipped. | ||
| /// Set in the constructor if any env var is missing. | ||
| /// </summary> | ||
| protected string? SkipReason { get; } | ||
|
|
||
| /// <summary> | ||
| /// Whether all required env vars are present. | ||
| /// Use this in test bodies to conditionally Skip: | ||
| /// <code>Skip.If(ShouldSkip, SkipReason);</code> | ||
| /// </summary> | ||
| protected bool ShouldSkip => SkipReason != null; | ||
|
|
||
| protected IntegrationTestBase(params string[] requiredEnvVars) | ||
| { | ||
| _requiredVars = requiredEnvVars; | ||
|
Comment on lines
+21
to
+38
|
||
|
|
||
| var missing = requiredEnvVars | ||
| .Where(v => string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(v))) | ||
| .ToList(); | ||
|
|
||
| if (missing.Count > 0) | ||
| { | ||
| SkipReason = $"Integration test skipped — missing env var(s): {string.Join(", ", missing)}"; | ||
| } | ||
| } | ||
|
Comment on lines
+36
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This implementation only reads process environment variables; it does not load values from 🤖 Prompt for AI Agents
Comment on lines
+36
to
+48
|
||
|
|
||
| /// <summary> | ||
| /// Reads a required env var. Throws if missing (only call after checking ShouldSkip). | ||
| /// </summary> | ||
| protected string GetRequiredEnv(string name) | ||
| { | ||
| var value = Environment.GetEnvironmentVariable(name); | ||
| if (string.IsNullOrWhiteSpace(value)) | ||
| throw new InvalidOperationException( | ||
| $"Required env var '{name}' is not set. Did you check ShouldSkip first?"); | ||
| return value; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Skips the current test if env vars are missing. | ||
| /// Call this at the top of every integration test method. | ||
| /// </summary> | ||
| protected void SkipIfMissingEnvVars() | ||
| { | ||
| if (ShouldSkip) | ||
| Skip.If(true, SkipReason!); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| using System.Net; | ||
| using System.Text; | ||
|
|
||
| namespace PayBridge.SDK.Test.Helpers; | ||
|
|
||
| /// <summary> | ||
| /// A fake HttpMessageHandler that returns pre-queued responses without hitting the network. | ||
| /// Supports multi-step gateways (e.g. Monnify/Interswitch that do OAuth then pay). | ||
| /// </summary> | ||
| public sealed class MockHttpMessageHandler : HttpMessageHandler | ||
| { | ||
| private readonly Queue<MockHttpResponse> _queue = new(); | ||
| private readonly List<HttpRequestMessage> _requests = new(); | ||
|
|
||
| /// <summary>All requests made through this handler, in order.</summary> | ||
| public IReadOnlyList<HttpRequestMessage> Requests => _requests.AsReadOnly(); | ||
|
|
||
| /// <summary>The most recent request made through this handler.</summary> | ||
| public HttpRequestMessage? LastRequest => _requests.Count > 0 ? _requests[^1] : null; | ||
|
|
||
| // ── Fluent setup ────────────────────────────────────────────────────────── | ||
|
|
||
| /// <summary>Queue a single JSON response.</summary> | ||
| public MockHttpMessageHandler RespondWith( | ||
| HttpStatusCode statusCode, | ||
| string jsonBody, | ||
| string contentType = "application/json") | ||
| { | ||
| _queue.Enqueue(new MockHttpResponse(statusCode, jsonBody, contentType)); | ||
| return this; | ||
| } | ||
|
Comment on lines
+24
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mock queue does not enforce expected HTTP method/URL per response. The handler currently dequeues the next response for any incoming request, so a wrong endpoint/method can still pass if the queue is non-empty. That misses the acceptance criterion for queued Also applies to: 33-41, 49-63 🤖 Prompt for AI Agents |
||
|
|
||
| /// <summary>Queue a plain-text / XML response.</summary> | ||
| public MockHttpMessageHandler RespondWithText( | ||
| HttpStatusCode statusCode, | ||
| string body, | ||
| string contentType = "text/plain") | ||
| { | ||
| _queue.Enqueue(new MockHttpResponse(statusCode, body, contentType)); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary>Queue an empty 200 OK response.</summary> | ||
| public MockHttpMessageHandler RespondWithOk() | ||
| => RespondWith(HttpStatusCode.OK, "{}"); | ||
|
|
||
| // ── Core override ───────────────────────────────────────────────────────── | ||
|
|
||
| protected override Task<HttpResponseMessage> SendAsync( | ||
| HttpRequestMessage request, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| _requests.Add(request); | ||
|
|
||
| if (_queue.Count == 0) | ||
| { | ||
| throw new InvalidOperationException( | ||
| $"MockHttpMessageHandler: no queued response for {request.Method} {request.RequestUri}. " + | ||
| "Did you forget to call RespondWith()?"); | ||
| } | ||
|
|
||
| var mock = _queue.Dequeue(); | ||
| var response = new HttpResponseMessage(mock.StatusCode) | ||
| { | ||
| Content = new StringContent(mock.Body, Encoding.UTF8, mock.ContentType) | ||
| }; | ||
|
Comment on lines
+49
to
+66
|
||
|
|
||
| return Task.FromResult(response); | ||
| } | ||
|
|
||
| // ── Assertion helpers ───────────────────────────────────────────────────── | ||
|
|
||
| /// <summary>Assert that the handler received exactly <paramref name="count"/> requests.</summary> | ||
| public void AssertRequestCount(int count) | ||
| { | ||
| if (_requests.Count != count) | ||
| throw new InvalidOperationException( | ||
| $"Expected {count} HTTP request(s) but got {_requests.Count}."); | ||
| } | ||
|
|
||
| /// <summary>Assert the last request targeted the expected URL path.</summary> | ||
| public void AssertLastRequestPath(string expectedPathContains) | ||
| { | ||
| var path = LastRequest?.RequestUri?.ToString() ?? "(none)"; | ||
| if (!path.Contains(expectedPathContains, StringComparison.OrdinalIgnoreCase)) | ||
| throw new InvalidOperationException( | ||
| $"Expected last request URL to contain '{expectedPathContains}' but was '{path}'."); | ||
| } | ||
|
|
||
| /// <summary>Assert the last request used the expected HTTP method.</summary> | ||
| public void AssertLastMethod(HttpMethod method) | ||
| { | ||
| if (LastRequest?.Method != method) | ||
| throw new InvalidOperationException( | ||
| $"Expected HTTP {method} but last request was {LastRequest?.Method}."); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Build an <see cref="HttpClient"/> wired to this handler. | ||
| /// Optionally sets a base address. | ||
| /// </summary> | ||
| public HttpClient BuildClient(string? baseAddress = null) | ||
| { | ||
| var client = new HttpClient(this); | ||
| if (baseAddress != null) | ||
| client.BaseAddress = new Uri(baseAddress); | ||
| return client; | ||
| } | ||
| } | ||
|
|
||
| /// <summary>A single pre-configured HTTP response in the queue.</summary> | ||
| public sealed record MockHttpResponse( | ||
| HttpStatusCode StatusCode, | ||
| string Body, | ||
| string ContentType); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shared root cause: checkout token persistence not disabled.
Both workflows should set
with: persist-credentials: falseonactions/checkoutto reduce credential exposure in downstream steps.🧰 Tools
🪛 zizmor (1.25.2)
[warning] 19-20: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
[error] 20-20: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
🤖 Prompt for AI Agents
Source: Linters/SAST tools
🧩 Analysis chain
🏁 Script executed:
Repository: teesofttech/PayBridge
Length of output: 495
🏁 Script executed:
Repository: teesofttech/PayBridge
Length of output: 11419
Pin GitHub Actions to immutable commit SHAs (avoid tag-based
uses:).CI/integration/release workflows use mutable major tags (no SHA), e.g.
actions/checkout@v4,actions/setup-dotnet@v4,actions/upload-artifact@v4,dorny/test-reporter@v1, andsoftprops/action-gh-release@v2(see.github/workflows/ci-tests.yml,.github/workflows/integration-tests.yml,.github/workflows/nuget-publish.yml). This creates a supply-chain risk if a tag is moved or a compromised action version is published.🧰 Tools
🪛 zizmor (1.25.2)
[warning] 19-20: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
[error] 20-20: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
🤖 Prompt for AI Agents
Source: Linters/SAST tools