Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4

- name: Test integration workflow gate
run: python3 -m unittest scripts/tests/test_integration_test_gate.py

- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
Expand Down Expand Up @@ -68,4 +71,3 @@ jobs:
reporter: dotnet-trx
fail-on-error: true
fail-on-empty: false

70 changes: 34 additions & 36 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ on:
workflow_dispatch:
inputs:
gateway:
description: 'Gateway to test (e.g. paystack, flutterwave, all)'
required: false
description: 'Sandbox provider test suite to run'
required: true
type: choice
options:
- all
- peachpayments
default: 'all'

# Also run nightly on master so regressions are caught early
# Run weekly on master; increase frequency once sandbox credentials are stable
schedule:
- cron: '0 2 * * *' # 02:00 UTC every day
- cron: '0 2 * * 1' # 02:00 UTC every Monday

jobs:
integration-tests:
Expand All @@ -33,58 +37,52 @@ jobs:
with:
dotnet-version: '8.0.x'

- name: Validate provider selection and secrets
id: preflight
env:
SELECTED_GATEWAY: ${{ inputs.gateway || 'all' }}
PEACH_ENTITY_ID: ${{ secrets.PEACH_ENTITY_ID }}
PEACH_ACCESS_TOKEN: ${{ secrets.PEACH_ACCESS_TOKEN }}
run: |
python3 scripts/integration_test_gate.py plan \
--gateway "$SELECTED_GATEWAY" \
--github-output "$GITHUB_OUTPUT"

- 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
id: integration_tests
continue-on-error: true
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 }}
PEACH_ENTITY_ID: ${{ secrets.PEACH_ENTITY_ID }}
PEACH_ACCESS_TOKEN: ${{ secrets.PEACH_ACCESS_TOKEN }}
run: |
dotnet test PayBridge.SDK.Test/PayBridge.SDK.Test.csproj \
--configuration Release \
--no-build \
--filter "Category=Integration" \
--filter "Category=Integration&${{ steps.preflight.outputs.test_filter }}" \
--logger "trx;LogFileName=integration-test-results.trx" \
--results-directory ./TestResults

- name: Validate and summarize integration results
if: always()
run: |
python3 scripts/integration_test_gate.py summarize \
--provider "${{ steps.preflight.outputs.provider }}" \
--trx ./TestResults/integration-test-results.trx \
--github-summary "$GITHUB_STEP_SUMMARY"

- name: Upload integration test results
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-test-results
path: ./TestResults/*.trx
if-no-files-found: error

- name: Publish test results
uses: dorny/test-reporter@v1
Expand All @@ -93,4 +91,4 @@ jobs:
name: Integration Test Results
path: ./TestResults/*.trx
reporter: dotnet-trx
fail-on-error: false # don't fail the workflow if sandbox keys are missing
fail-on-error: false # the tested result gate above owns job failure
38 changes: 38 additions & 0 deletions docs/integration-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Sandbox integration testing

PayBridge's scheduled integration workflow must prove that at least one real
sandbox test passed. Missing credentials, a missing result file, failed tests,
or a result containing only skipped tests fail the job.

## Currently implemented provider suite

| Provider input | Test class | Required `integration` environment secrets |
|---|---|---|
| `peachpayments` | `PeachPaymentsIntegrationTests` | `PEACH_ENTITY_ID`, `PEACH_ACCESS_TOKEN` |

Add both secrets under **Settings → Environments → integration → Environment
secrets**. They remain scoped to jobs that reference the `integration`
environment and are not passed to build, reporting, or artifact-upload steps.

Manual runs expose only `all` and providers with implemented sandbox tests.
Selecting one provider applies an exact fully-qualified-name test filter. The
scheduled `all` run executes configured provider suites and fails during
preflight when none are configured.
Comment on lines +17 to +20

Each run uploads the TRX file and writes a provider summary containing passed,
failed, skipped, and total counts. The tested result gate, rather than the test
reporting action, owns the final pass/fail decision.

## Local gate tests

```bash
python3 -m unittest scripts/tests/test_integration_test_gate.py
```

Run the preflight locally without printing credential values:

```bash
PEACH_ENTITY_ID="<sandbox-entity>" \
PEACH_ACCESS_TOKEN="<sandbox-token>" \
python3 scripts/integration_test_gate.py plan --gateway peachpayments
```
159 changes: 159 additions & 0 deletions scripts/integration_test_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Plan and validate PayBridge sandbox integration-test runs."""

import argparse
import os
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Mapping, NamedTuple, Sequence


class Provider(NamedTuple):
name: str
required_secrets: tuple[str, ...]
test_filter: str


class ProviderSummary(NamedTuple):
provider: str
passed: int
failed: int
skipped: int
total: int


PROVIDERS = {
"peachpayments": Provider(
name="peachpayments",
required_secrets=("PEACH_ENTITY_ID", "PEACH_ACCESS_TOKEN"),
test_filter="FullyQualifiedName~PayBridge.SDK.Test.Integration.PeachPayments",
),
}


def create_plan(gateway: str, environment: Mapping[str, str]) -> list[Provider]:
selected = gateway.strip().lower()
if selected != "all" and selected not in PROVIDERS:
supported = ", ".join(["all", *PROVIDERS])
raise ValueError(f"Unsupported gateway '{gateway}'. Supported values: {supported}")

candidates = list(PROVIDERS.values()) if selected == "all" else [PROVIDERS[selected]]
configured = []
missing_by_provider = {}
for provider in candidates:
missing = [
secret
for secret in provider.required_secrets
if not environment.get(secret, "").strip()
]
if missing:
missing_by_provider[provider.name] = missing
else:
configured.append(provider)

if selected != "all" and missing_by_provider:
missing = ", ".join(missing_by_provider[selected])
raise ValueError(f"Missing required secrets for {selected}: {missing}")

if not configured:
details = "; ".join(
f"{name}: {', '.join(missing)}"
for name, missing in missing_by_provider.items()
)
raise ValueError(f"No sandbox provider is configured. {details}")

return configured


def read_trx(path: Path, provider: str) -> ProviderSummary:
if not path.is_file():
raise ValueError(f"TRX result file does not exist: {path}")

root = ET.parse(path).getroot()
counters = root.find(".//{*}Counters")
if counters is None:
raise ValueError(f"TRX result file has no Counters element: {path}")

passed = int(counters.get("passed", "0"))
failed = int(counters.get("failed", "0"))
Comment on lines +78 to +79
result_outcomes = [
result.get("outcome", "")
for result in root.findall(".//{*}UnitTestResult")
]
skipped = max(
int(counters.get("notExecuted", "0")),
sum(outcome == "NotExecuted" for outcome in result_outcomes),
)
total = int(counters.get("total", str(passed + failed + skipped)))
return ProviderSummary(provider, passed, failed, skipped, total)


def validate_summaries(summaries: Sequence[ProviderSummary]) -> None:
if any(summary.failed > 0 for summary in summaries):
raise ValueError("Integration run contains failed sandbox tests")
if sum(summary.passed for summary in summaries) == 0:
raise ValueError("Integration run completed with zero passing sandbox tests")


def markdown_summary(summaries: Sequence[ProviderSummary]) -> str:
lines = [
"## Sandbox integration results",
"",
"| Provider | Passed | Failed | Skipped | Total |",
"|---|---:|---:|---:|---:|",
]
lines.extend(
f"| {item.provider} | {item.passed} | {item.failed} | "
f"{item.skipped} | {item.total} |"
for item in summaries
)
return "\n".join(lines) + "\n"


def write_github_output(plan: Sequence[Provider], path: Path) -> None:
if len(plan) != 1:
raise ValueError("The workflow currently supports one configured provider per job")
with path.open("a", encoding="utf-8") as output:
output.write(f"provider={plan[0].name}\n")
output.write(f"test_filter={plan[0].test_filter}\n")


def main() -> int:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)

plan_parser = subparsers.add_parser("plan")
plan_parser.add_argument("--gateway", required=True)
plan_parser.add_argument("--github-output", type=Path)

summary_parser = subparsers.add_parser("summarize")
summary_parser.add_argument("--provider", required=True)
summary_parser.add_argument("--trx", required=True, type=Path)
summary_parser.add_argument("--github-summary", type=Path)

args = parser.parse_args()
try:
if args.command == "plan":
plan = create_plan(args.gateway, os.environ)
if args.github_output:
write_github_output(plan, args.github_output)
else:
print(plan[0].test_filter)
else:
summaries = [read_trx(args.trx, args.provider)]
report = markdown_summary(summaries)
if args.github_summary:
with args.github_summary.open("a", encoding="utf-8") as output:
output.write(report)
else:
print(report, end="")
validate_summaries(summaries)
except (OSError, ET.ParseError, ValueError) as error:
print(f"integration-test-gate: {error}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading