From 5049bc9744786081e2c4e9626ff0523b6fc2dd36 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 18 Jul 2026 04:59:54 +0100 Subject: [PATCH] Require passing sandbox integration tests --- .github/workflows/ci-tests.yml | 4 +- .github/workflows/integration-tests.yml | 70 +++++---- docs/integration-testing.md | 38 +++++ scripts/integration_test_gate.py | 159 ++++++++++++++++++++ scripts/tests/test_integration_test_gate.py | 125 +++++++++++++++ 5 files changed, 359 insertions(+), 37 deletions(-) create mode 100644 docs/integration-testing.md create mode 100644 scripts/integration_test_gate.py create mode 100644 scripts/tests/test_integration_test_gate.py diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 73fb8c3..5567d37 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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: @@ -68,4 +71,3 @@ jobs: reporter: dotnet-trx fail-on-error: true fail-on-empty: false - diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index fea9821..c5f9e51 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -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: @@ -33,6 +37,17 @@ 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 @@ -40,51 +55,34 @@ jobs: 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 @@ -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 diff --git a/docs/integration-testing.md b/docs/integration-testing.md new file mode 100644 index 0000000..0b2c941 --- /dev/null +++ b/docs/integration-testing.md @@ -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. + +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="" \ +PEACH_ACCESS_TOKEN="" \ +python3 scripts/integration_test_gate.py plan --gateway peachpayments +``` diff --git a/scripts/integration_test_gate.py b/scripts/integration_test_gate.py new file mode 100644 index 0000000..ed89bd9 --- /dev/null +++ b/scripts/integration_test_gate.py @@ -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")) + 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()) diff --git a/scripts/tests/test_integration_test_gate.py b/scripts/tests/test_integration_test_gate.py new file mode 100644 index 0000000..4fa4f87 --- /dev/null +++ b/scripts/tests/test_integration_test_gate.py @@ -0,0 +1,125 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).parents[1] / "integration_test_gate.py" +WORKFLOW_PATH = Path(__file__).parents[2] / ".github/workflows/integration-tests.yml" +CI_WORKFLOW_PATH = Path(__file__).parents[2] / ".github/workflows/ci-tests.yml" +SPEC = importlib.util.spec_from_file_location("integration_test_gate", MODULE_PATH) +gate = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(gate) + + +class IntegrationTestGateTests(unittest.TestCase): + def test_workflow_uses_preflight_filter_and_result_gate(self): + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + + self.assertIn("integration_test_gate.py plan", workflow) + self.assertIn("steps.preflight.outputs.test_filter", workflow) + self.assertIn("integration_test_gate.py summarize", workflow) + + def test_manual_gateway_input_only_offers_implemented_provider_tests(self): + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + + self.assertIn("type: choice", workflow) + self.assertIn("- peachpayments", workflow) + self.assertNotIn("e.g. paystack, flutterwave", workflow) + + def test_pull_request_ci_runs_gate_unit_tests(self): + workflow = CI_WORKFLOW_PATH.read_text(encoding="utf-8") + + self.assertIn("python3 -m unittest scripts/tests/test_integration_test_gate.py", workflow) + + def test_plan_rejects_unknown_provider(self): + with self.assertRaisesRegex(ValueError, "Unsupported gateway"): + gate.create_plan("paystack", {}) + + def test_plan_rejects_selected_provider_with_missing_secrets(self): + with self.assertRaisesRegex(ValueError, "PEACH_ACCESS_TOKEN"): + gate.create_plan("peachpayments", {"PEACH_ENTITY_ID": "entity"}) + + def test_all_selects_only_configured_provider_tests(self): + plan = gate.create_plan( + "all", + { + "PEACH_ENTITY_ID": "entity", + "PEACH_ACCESS_TOKEN": "token", + }, + ) + + self.assertEqual(["peachpayments"], [provider.name for provider in plan]) + self.assertEqual( + "FullyQualifiedName~PayBridge.SDK.Test.Integration.PeachPayments", + plan[0].test_filter, + ) + + def test_all_rejects_run_when_no_provider_is_configured(self): + with self.assertRaisesRegex(ValueError, "No sandbox provider"): + gate.create_plan("all", {}) + + def test_summary_reports_provider_counts(self): + result_file = self._write_trx(passed=2, failed=0, skipped=1) + + summary = gate.read_trx(result_file, "peachpayments") + + self.assertEqual(2, summary.passed) + self.assertEqual(0, summary.failed) + self.assertEqual(1, summary.skipped) + self.assertEqual(3, summary.total) + + def test_summary_rejects_zero_passed_tests(self): + result_file = self._write_trx(passed=0, failed=0, skipped=2) + + summary = gate.read_trx(result_file, "peachpayments") + + with self.assertRaisesRegex(ValueError, "zero passing sandbox tests"): + gate.validate_summaries([summary]) + + def test_summary_rejects_failed_tests(self): + result_file = self._write_trx(passed=1, failed=1, skipped=0) + + summary = gate.read_trx(result_file, "peachpayments") + + with self.assertRaisesRegex(ValueError, "failed sandbox tests"): + gate.validate_summaries([summary]) + + def test_summary_counts_xunit_skips_from_individual_results(self): + result_file = self._write_trx(passed=0, failed=0, skipped=2, counter_skipped=0) + + summary = gate.read_trx(result_file, "peachpayments") + + self.assertEqual(2, summary.skipped) + + def _write_trx(self, passed, failed, skipped, counter_skipped=None): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + path = Path(directory.name) / "results.trx" + results = "\n".join( + [' '] * passed + + [' '] * failed + + [' '] * skipped + ) + reported_skipped = skipped if counter_skipped is None else counter_skipped + path.write_text( + f''' + + +{results} + + + + +''', + encoding="utf-8", + ) + return path + + +if __name__ == "__main__": + unittest.main()