From 2be210670f56442186ff98b0ce33b7cbd712e28f Mon Sep 17 00:00:00 2001 From: SimonZanta Date: Tue, 21 Jul 2026 12:41:11 +0200 Subject: [PATCH 1/4] Add fixture-backed functional coverage for SDK workflows --- FUNCTIONAL_INTEGRATION_COVERAGE_REPORT.md | 114 +++++++++++ tests/Integration/FixtureClientCest.php | 126 ++++++++++++ tests/Integration/FixtureMotoPaymentCest.php | 83 ++++++++ .../Integration/FixtureTerminalClientCest.php | 66 +++++++ tests/Integration/FixtureTransferCest.php | 164 +++++++++++++++ tests/Integration/FixtureTransportCest.php | 149 ++++++++++++++ tests/Support/Fixture/IntegrationApi.php | 171 ++++++++++++++++ tests/Support/Fixture/RecordingLogger.php | 23 +++ tests/Support/Fixture/router.php | 187 ++++++++++++++++++ 9 files changed, 1083 insertions(+) create mode 100644 FUNCTIONAL_INTEGRATION_COVERAGE_REPORT.md create mode 100644 tests/Integration/FixtureClientCest.php create mode 100644 tests/Integration/FixtureMotoPaymentCest.php create mode 100644 tests/Integration/FixtureTerminalClientCest.php create mode 100644 tests/Integration/FixtureTransferCest.php create mode 100644 tests/Integration/FixtureTransportCest.php create mode 100644 tests/Support/Fixture/IntegrationApi.php create mode 100644 tests/Support/Fixture/RecordingLogger.php create mode 100644 tests/Support/Fixture/router.php diff --git a/FUNCTIONAL_INTEGRATION_COVERAGE_REPORT.md b/FUNCTIONAL_INTEGRATION_COVERAGE_REPORT.md new file mode 100644 index 0000000..3f54d19 --- /dev/null +++ b/FUNCTIONAL_INTEGRATION_COVERAGE_REPORT.md @@ -0,0 +1,114 @@ +# Functional/Integration Coverage Report + +Generated from: + +```bash +XDEBUG_MODE=coverage vendor/bin/codecept run -v --coverage --coverage-xml coverage.xml --coverage-html +``` + +## Baseline and validity + +The requested command initially stopped because Xdebug coverage mode was disabled. Re-running it with `XDEBUG_MODE=coverage` generated: + +- HTML: `tests/_output/coverage/index.html` +- Clover XML: `tests/_output/coverage.xml` +- 65 tests, 1,085 assertions +- 26 passed, 36 errors, 3 failures +- classes: 6.58% (5/76) +- methods: 26.93% (188/698) +- lines: 25.53% (437/1,712) + +This is a partial baseline, not the SDK's healthy-suite coverage. The configured API service was unavailable. Most `ClientCest` cases therefore stopped in `Transport`, and `PaymentMethodSyncCest` could not load/parse the remote OpenAPI document. The three assertion failures are consequences of the same outage: tests expected an API exception but received a connection exception. + +Before using future percentages for acceptance decisions, restore the service/DNS route and rerun. Do not add tests merely to replace the existing live API cases that failed in this run. + +## Recommended test architecture + +Add a functional HTTP contract suite backed by a local fixture server (or an existing containerized fake Comgate service). Exercise the public SDK clients with the real `Transport`; return controlled HTTP headers and bodies from the fixture. This remains an integration/functional test because it crosses the public client, request DTO, cURL transport, PSR response/stream, and response DTO boundaries. It also avoids brittle dependence on a remote sandbox for most coverage. + +Keep a small, separately tagged live-API smoke suite for genuine Comgate compatibility. Do not replace the OpenAPI synchronization check with coverage-oriented tests; stabilize it by using a versioned OpenAPI fixture for the normal suite and optionally verify the live document in a scheduled contract job. + +## Prioritized backlog + +### P0 — deterministic payment API journeys + +Create table-driven integration scenarios against the fixture server for these public `Client` calls: + +1. `createPayment` returns a successful JSON payload; assert the received method, Basic Auth header, JSON body, and hydrated transaction ID/redirect. +2. `getStatus` returns a payload containing every optional status field (fee, card fields, payer fields, error reason); assert representative typed values and `toArray()`. +3. `cancelPayment`, `capturePreauth`, `cancelPreauth`, `refundPayment`, `initRecurringPayment`, and `simulation` each assert the HTTP verb, URN, serialized request, and hydrated success response. +4. For one operation, return an API error payload and assert the SDK's domain exception mapping. Add missing-parameter and payment-not-found payloads if the protocol distinguishes them. +5. For one GET and one POST/PUT, return HTTP 4xx and 5xx responses with a recording logger and assert observable SDK behavior plus error/critical log classification. + +Why first: this traverses the central production path and can recover large uncovered blocks in `Client` (16/76 lines currently), `Transport` (28/102), request objects, and response objects. `PaymentStatusResponse` alone has 141 uncovered executable lines; use an all-fields payload rather than tests of individual getters. + +### P0 — terminal end-to-end workflows + +Add `ClientTerminalCest` using the same HTTP fixture: + +1. Payment lifecycle: create, get status, cancel. +2. Refund lifecycle: create, get status, cancel. +3. Closing and terminal-status calls. +4. Assert that status/cancel calls replace `{transId}` and URL-encode or reject unsafe IDs according to the intended contract. +5. Use full status fixtures so nested/optional fields and `toArray()` are observed through public client calls. + +Why first: the entire terminal surface is uncovered: `ClientTerminal` 0/26 lines, `TerminalPaymentStatusResponse` 0/60, `TerminalRefundStatusResponse` 0/44, plus the terminal request/entity/response classes. Eight compact journey scenarios should cover most of this family without unit tests. + +### P1 — transfer and downloadable-file workflows + +Add fixture-backed public-client scenarios for: + +1. `transferList` with multiple transfers and `singleTransfer` with multiple payments; assert collection count and representative hydrated fields. +2. CSV and ABO single-transfer responses with successful file content and API-error content. +3. `getCsvDownload` and `getAboDownload` with attachment headers, filename parsing, non-empty binary/text content, ABO type/encoding variants, and write-to-temporary-file behavior where exposed. +4. Apple domain association with and without method/currency filters and a successful file payload. + +Why next: this covers related behavior in one coherent workflow, including `Transfer` (0/21), file response variants, download requests, `AppleDomainAssociationRequest` (0/19), and current gaps in `FileResponse`. Use temporary paths and public operations; do not directly test DTO getters in isolation. + +### P1 — MOTO encrypted-payment journey + +Add public `createMotoPayment` integration scenarios with a fixture endpoint that first returns a generated test RSA public JWK and then captures the payment request: + +1. Valid card: decrypt captured fields with the test private key and assert number, expiry, and CVV round-trip. +2. Missing public key response. +3. Missing card number. +4. Missing expiration; optionally verify that CVV is optional if that is the intended API contract. + +Why next: one workflow exercises `PaymentCard` (0/23), `MotoPayment` (0/12), public-key request/response (0/26 combined), MOTO request, encryption branches, and `MotoPaymentCreateResponse` (0/34). + +### P2 — transport protocol compatibility + +Add black-box integration scenarios through `Client`/`ClientTerminal` (or a small public test-only endpoint adapter if necessary): + +1. Response with several headers, repeated headers, and a body; verify downstream file/JSON parsing to cover header splitting and PSR adapters. +2. Body-only response without the `\r\n\r\n` separator. +3. Empty body, malformed JSON, and truncated response; assert the documented exception or fallback behavior. +4. Connection refusal/timeout to a deliberately unused local port; assert `ComgateException` and logger output. +5. Run one case with the `sdk-github` environment headers, supplying fake Cloudflare credentials and asserting both authorization header sets reach the fixture. + +Why later: `PsrStream` (0/31), `PsrResponse` (0/23), `Response` (0/7), and `Query` (0/7) are completely uncovered, while most transport branches are also uncovered. These tests should validate protocol behavior from an SDK boundary, not become unit tests for each PSR method. + +## Suggested implementation order + +1. Build the reusable local HTTP fixture and request recorder. +2. Add payment happy paths plus one API error and one connection error. +3. Add terminal workflows. +4. Add transfer/download workflows. +5. Add MOTO encryption. +6. Add the remaining transport edge cases. +7. Rerun the full suite with a healthy configured API service and compare Clover totals after each group. + +## Coverage target guidance + +Track line gains by workflow, but gate on behavior rather than a raw percentage. A practical first milestone is to cover every public operation in `Client` and `ClientTerminal` through at least one successful integration path and every transport verb through success plus one failure path. The current report shows 1,275 uncovered executable lines; the P0 groups address the largest cohesive production paths and should be implemented before isolated low-line-count classes. + +## Exclusions + +The following are intentionally not recommended: + +- direct tests of individual getters/setters, constructors, enums, or private methods; +- mocks of `ITransport` solely to assert that a client method called it; +- reflection-only coverage tests; +- duplicating live API scenarios solely because the service was unavailable during this run. + +Those approaches are unit tests or coverage gaming and do not meet the requested functional/integration scope. diff --git a/tests/Integration/FixtureClientCest.php b/tests/Integration/FixtureClientCest.php new file mode 100644 index 0000000..3ed08f7 --- /dev/null +++ b/tests/Integration/FixtureClientCest.php @@ -0,0 +1,126 @@ +api = new IntegrationApi(); + $this->api->start(); + } + + public function _after(): void + { + $this->api->stop(); + } + + #[Group('payment')] + #[Group('fixture')] + public function paymentLifecycleUsesExpectedProtocol(IntegrationTester $I): void + { + $client = $this->api->client(); + $payment = $I->createPayment(); + $created = $client->createPayment($payment); + + $I->assertSame('PAY-123', $created->getTransId()); + $I->assertSame('https://pay.test/PAY-123', $created->getRedirect()); + $I->assertSame('PAY-123', $created->toArray()['transId']); + + $status = $client->getStatus($created->getTransId()); + $I->assertTrue($status->isTest()); + $I->assertSame(12345, $status->getPrice()->get()); + $I->assertSame('CARD_CZ_CSOB_2', $status->getMethod()); + $I->assertSame('************1111', $status->getCardNumber()); + $I->assertSame('fixed', $status->toArray()['appliedFeeTyp']); + + $cancelled = $client->cancelPayment($created->getTransId()); + $I->assertSame(['code' => 0, 'message' => 'OK'], $cancelled->toArray()); + + $requests = $this->api->requests(); + $I->assertSame('POST', $requests[0]['method']); + $I->assertSame('/payment.json', $requests[0]['path']); + $I->assertSame('Basic ' . base64_encode('fixture-merchant:fixture-secret'), $requests[0]['headers']['Authorization']); + $I->assertSame('SDK test payment', json_decode($requests[0]['body'], true)['label']); + $I->assertSame('GET', $requests[1]['method']); + $I->assertSame('/payment/transId/PAY-123.json', $requests[1]['path']); + $I->assertSame('DELETE', $requests[2]['method']); + } + + #[Group('payment')] + #[Group('fixture')] + public function paymentOperationsSerializeAndHydrate(IntegrationTester $I): void + { + $client = $this->api->client(); + $I->assertSame(0, $client->capturePreauth('PAY/A B', Money::ofCents(500))->getCode()); + $I->assertSame(0, $client->cancelPreauth('PAY/A B')->getCode()); + + $refund = (new Refund()) + ->setTransId('PAY-123') + ->setAmount(Money::ofCents(250)) + ->setCurrency(CurrencyCode::CZK) + ->setTest(true) + ->setRefId('REF-1'); + $I->assertSame(0, $client->refundPayment($refund)->getCode()); + + $recurring = $I->createPayment(); + $recurring->setInitRecurringId('PAY-123'); + $I->assertSame('REC-123', $client->initRecurringPayment($recurring)->getTransId()); + $I->assertSame(0, $client->simulation(['subject' => 'payment', 'transId' => 'PAY-123', 'task' => 'PAID'])->getCode()); + + $requests = $this->api->requests(); + $I->assertSame('PUT', $requests[0]['method']); + $I->assertSame('/preauth/transId/PAY%2FA+B.json', $requests[0]['path']); + $I->assertSame(500, json_decode($requests[0]['body'], true)['amount']); + $I->assertSame('DELETE', $requests[1]['method']); + $I->assertSame('/refund.json', $requests[2]['path']); + $I->assertSame(250, json_decode($requests[2]['body'], true)['amount']); + $I->assertSame('/recurring.json', $requests[3]['path']); + $I->assertSame('/simulation.json', $requests[4]['path']); + } + + #[Group('payment')] + #[Group('fixture')] + #[DataProvider('apiErrors')] + public function mapsApiErrors(IntegrationTester $I, Example $example): void + { + $client = $this->api->client($example['mode']); + $I->expectThrowable($example['exception'], function () use ($I, $client, $example): void { + if ($example['operation'] === 'status') { + $client->getStatus('missing'); + return; + } + if ($example['operation'] === 'capture') { + $client->capturePreauth('PAY-1', Money::ofCents(100)); + return; + } + $client->createPayment($I->createPayment()); + }); + } + + protected function apiErrors(): array + { + return [ + 'general API error' => ['mode' => 'api-error', 'operation' => 'status', 'exception' => ApiException::class], + 'payment not found' => ['mode' => 'missing', 'operation' => 'status', 'exception' => PaymentNotFoundException::class], + 'preauth rejected' => ['mode' => 'preauth-error', 'operation' => 'capture', 'exception' => PreauthException::class], + 'missing create parameter' => ['mode' => 'missing', 'operation' => 'create', 'exception' => MissingParamException::class], + ]; + } +} diff --git a/tests/Integration/FixtureMotoPaymentCest.php b/tests/Integration/FixtureMotoPaymentCest.php new file mode 100644 index 0000000..ffb55d6 --- /dev/null +++ b/tests/Integration/FixtureMotoPaymentCest.php @@ -0,0 +1,83 @@ +api = new IntegrationApi(); + $this->api->start(); + } + + public function _after(): void + { + $this->api->stop(); + } + + #[Group('moto')] + #[Group('fixture')] + public function encryptsCardDataBeforeSending(IntegrationTester $I): void + { + $payment = $I->createPayment(); + $card = new PaymentCard('4111111111111111', '203012', '123'); + $response = $this->api->client()->createMotoPayment($payment, $card); + $I->assertSame('MOTO-1', $response->getTransId()); + $I->assertSame('PAID', $response->toArray()['status']); + + $body = json_decode($this->api->requests()[1]['body'], true); + $I->assertSame('4111111111111111', $this->api->decrypt($body['encryptedCardNumber'])); + $I->assertSame('203012', $this->api->decrypt($body['encryptedCardExpiration'])); + $I->assertSame('123', $this->api->decrypt($body['encryptedCardCvv'])); + $I->assertSame('/pubCryptoKey.json', $this->api->requests()[0]['path']); + $I->assertSame('/moto.json', $this->api->requests()[1]['path']); + } + + #[Group('moto')] + #[Group('fixture')] + public function rejectsMissingPublicKey(IntegrationTester $I): void + { + $I->expectThrowable(new Exception('No public encryption key for encrypting card data'), function () use ($I): void { + $this->api->client('no-key')->createMotoPayment($I->createPayment(), new PaymentCard('4111111111111111', '203012', '123')); + }); + } + + #[Group('moto')] + #[Group('fixture')] + #[DataProvider('missingRequiredCardFields')] + public function rejectsMissingRequiredCardFields(IntegrationTester $I, Example $example): void + { + $I->expectThrowable(new Exception($example['message']), function () use ($I, $example): void { + $card = new PaymentCard($example['number'], $example['expiration'], '123'); + $this->api->client()->createMotoPayment($I->createPayment(), $card); + }); + } + + protected function missingRequiredCardFields(): array + { + return [ + 'card number' => ['number' => null, 'expiration' => '203012', 'message' => 'No card number for encrypting card data'], + 'expiration' => ['number' => '4111111111111111', 'expiration' => null, 'message' => 'No card expiration for encrypting card data'], + ]; + } + + #[Group('moto')] + #[Group('fixture')] + public function permitsMissingCvv(IntegrationTester $I): void + { + $response = $this->api->client()->createMotoPayment($I->createPayment(), new PaymentCard('4111111111111111', '203012')); + $I->assertSame('MOTO-1', $response->getTransId()); + $I->assertArrayNotHasKey('encryptedCardCvv', json_decode($this->api->requests()[1]['body'], true)); + } +} diff --git a/tests/Integration/FixtureTerminalClientCest.php b/tests/Integration/FixtureTerminalClientCest.php new file mode 100644 index 0000000..45ab4e4 --- /dev/null +++ b/tests/Integration/FixtureTerminalClientCest.php @@ -0,0 +1,66 @@ +api = new IntegrationApi(); + $this->api->start(); + } + + public function _after(): void + { + $this->api->stop(); + } + + #[Group('terminal')] + #[Group('fixture')] + public function paymentLifecycle(IntegrationTester $I): void + { + $client = $this->api->terminalClient(); + $payment = (new TerminalPayment())->setPrice(12.50)->setCurr('CZK')->setRefId('TP-1'); + $created = $client->createPayment($payment); + $I->assertSame('TERM-PAY-1', $created->toArray()['transId']); + + $status = $client->getPaymentStatus('TERM-PAY-1'); + $I->assertSame('PAID', $status->getStatus()); + $I->assertSame(1250, $status->toArray()['price']); + $I->assertFalse($status->isReversed()); + $I->assertSame(0, $client->cancelPayment('TERM-PAY-1')->getCode()); + + $requests = $this->api->requests(); + $I->assertSame(['price' => 1250, 'curr' => 'CZK', 'refId' => 'TP-1'], json_decode($requests[0]['body'], true)); + $I->assertSame('/terminalPayment/transId/TERM-PAY-1.json', $requests[1]['path']); + $I->assertSame('DELETE', $requests[2]['method']); + } + + #[Group('terminal')] + #[Group('fixture')] + public function refundClosingAndStatusLifecycle(IntegrationTester $I): void + { + $client = $this->api->terminalClient(); + $refund = (new TerminalRefund())->setPrice(5)->setCurr('CZK')->setRefId('TR-1'); + $I->assertSame('TERM-REF-1', $client->createRefund($refund)->getTransId()); + + $status = $client->getRefundStatus('TERM-REF-1'); + $I->assertSame('REFUNDED', $status->getStatus()); + $I->assertTrue($status->isReversed()); + $I->assertSame(0, $client->cancelRefund('TERM-REF-1')->getCode()); + + $closing = $client->createClosing(); + $I->assertSame(12, $closing->getBatchNumber()); + $I->assertSame(2, $closing->toArray()['batchData'][0]['count']); + $I->assertSame('ONLINE', $client->getTerminalStatus()->toArray()['status']); + } +} diff --git a/tests/Integration/FixtureTransferCest.php b/tests/Integration/FixtureTransferCest.php new file mode 100644 index 0000000..c643719 --- /dev/null +++ b/tests/Integration/FixtureTransferCest.php @@ -0,0 +1,164 @@ +api = new IntegrationApi(); + $this->api->start(); + } + + public function _after(): void + { + $this->api->stop(); + } + + #[Group('transfer')] + #[Group('fixture')] + public function hydratesTransferCollections(IntegrationTester $I): void + { + $client = $this->api->client(); + $transfers = $client->transferList(new DateTimeImmutable('2026-07-21'), true)->getTransferList(); + $I->assertCount(2, $transfers); + $I->assertSame(1, $transfers[0]->getTransferId()); + $I->assertSame('2026-07-20', $transfers[0]->getTransferDate()->format('Y-m-d')); + $I->assertSame('43', $transfers[1]->getVariableSymbol()); + + $payments = $client->singleTransfer(42, true)->getPaymentsList(); + $I->assertCount(2, $payments); + $I->assertSame('PAY-1', $payments[0]->getData()['transId']); + + $requests = $this->api->requests(); + $I->assertSame('/transferList/date/2026-07-21.json', $requests[0]['path']); + $I->assertSame(['test' => 'true'], $requests[0]['query']); + $I->assertSame('/singleTransfer/transferId/42.json', $requests[1]['path']); + } + + #[Group('transfer')] + #[Group('fixture')] + public function savesCsvTransferToTemporaryFile(IntegrationTester $I): void + { + $response = $this->api->client()->csvSingleTransfer('42', true); + $I->assertSame('transfer.csv', $response->getFilename()); + $I->assertStringContainsString('1,100', $response->getFileContent()); + + $directory = sys_get_temp_dir() . '/comgate-csv-' . uniqid('', true); + mkdir($directory); + $response->saveToFile($directory); + $I->assertSame($response->getFileContent(), file_get_contents($directory . '/transfer.csv')); + unlink($directory . '/transfer.csv'); + rmdir($directory); + } + + #[Group('transfer')] + #[Group('fixture')] + #[DataProvider('aboFormats')] + public function handlesAboTransferFormats(IntegrationTester $I, Example $example): void + { + $response = $this->api->client()->aboSingleTransfer('42', true, $example['type'], $example['encoding']); + $I->assertSame('transfer.abo', $response->getFilename()); + $I->assertSame('UHL1 0100 100', $response->getFileContent()); + $request = $this->api->requests()[0]; + $I->assertSame($example['type'], $request['query']['type']); + $I->assertSame($example['encoding'], $request['query']['encoding']); + } + + protected function aboFormats(): array + { + return [ + 'v1 utf8' => ['type' => AboSingleTransferRequest::ABO_TYPE_V1, 'encoding' => AboSingleTransferRequest::ABO_ENCODING_UTF8], + 'v2 windows' => ['type' => AboSingleTransferRequest::ABO_TYPE_V2, 'encoding' => AboSingleTransferRequest::ABO_ENCODING_WINDOWS], + ]; + } + + #[Group('transfer')] + #[Group('fixture')] + public function mapsTransferFileApiErrors(IntegrationTester $I): void + { + $I->expectThrowable(ApiException::class, function (): void { + $this->api->client('api-error')->csvSingleTransfer('42', false); + }); + } + + #[Group('transfer')] + #[Group('fixture')] + public function requestsAppleAssociationWithFilters(IntegrationTester $I): void + { + $response = $this->api->client()->getAppleDomainAssociation('APPLE_PAY', 'CZK'); + $I->assertSame('Comgate\SDK\Entity\Response\AppleDomainAssociationResponse', get_class($response)); + $request = $this->api->requests()[0]; + $I->assertSame('CZK', $request['query']['currency']); + $I->assertArrayNotHasKey('method', $request['query']); + } + + #[Group('transfer')] + #[Group('fixture')] + #[DataProvider('downloads')] + public function streamsDownloadsInSubprocess(IntegrationTester $I, Example $example): void + { + $autoload = dirname(__DIR__, 2) . '/vendor/autoload.php'; + $source = dirname(__DIR__, 2) . '/src'; + $sourceFiles = []; + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source)) as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $sourceFiles[] = $file->getPathname(); + } + } + $coverageFile = tempnam(sys_get_temp_dir(), 'comgate-download-coverage-'); + $I->assertNotFalse($coverageFile); + $code = 'require ' . var_export($autoload, true) . '; ' + . '$filter=new \SebastianBergmann\CodeCoverage\Filter(); ' + . '$filter->includeFiles(' . var_export($sourceFiles, true) . '); ' + . '$driver=(new \SebastianBergmann\CodeCoverage\Driver\Selector())->forLineCoverage($filter); ' + . '$coverage=new \SebastianBergmann\CodeCoverage\CodeCoverage($driver,$filter); ' + . '$coverage->start("' . $example['method'] . '"); ' + . 'register_shutdown_function(function()use($coverage){$coverage->stop();file_put_contents(' + . var_export($coverageFile, true) . ',serialize($coverage));}); ' + . '$client=\Comgate\SDK\Comgate::defaults()->setMerchant("fixture-merchant")->setSecret("fixture-secret")->setUrl(' . var_export($this->api->url(), true) . ')->createClient(); ' + . '$client->' . $example['method'] . '(' . $example['arguments'] . ');'; + $process = proc_open( + [PHP_BINARY, '-d', 'xdebug.mode=coverage', '-r', $code], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + array_merge($_ENV, ['XDEBUG_MODE' => 'coverage']) + ); + $I->assertIsResource($process); + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $I->assertSame(0, proc_close($process), $error); + $I->assertStringContainsString($example['content'], $output); + $I->assertSame($example['path'], $this->api->requests()[0]['path']); + $downloadCoverage = unserialize(file_get_contents($coverageFile)); + $I->assertInstanceOf(\SebastianBergmann\CodeCoverage\CodeCoverage::class, $downloadCoverage); + if (getenv('XDEBUG_MODE') === 'coverage') { + PhpCodeCoverageFactory::build()->merge($downloadCoverage); + } + unlink($coverageFile); + } + + protected function downloads(): array + { + return [ + 'CSV' => ['method' => 'getCsvDownload', 'arguments' => '"2026-07-21", true', 'content' => 'daily.csv', 'path' => '/csvDownload/date/2026-07-21'], + 'ABO' => ['method' => 'getAboDownload', 'arguments' => '"2026-07-21", "v2", true, "utf8"', 'content' => 'daily.abo', 'path' => '/aboDownload/date/2026-07-21'], + ]; + } +} diff --git a/tests/Integration/FixtureTransportCest.php b/tests/Integration/FixtureTransportCest.php new file mode 100644 index 0000000..8cc3e13 --- /dev/null +++ b/tests/Integration/FixtureTransportCest.php @@ -0,0 +1,149 @@ +api = new IntegrationApi(); + $this->api->start(); + } + + public function _after(): void + { + unset($_ENV['APPLICATION_ENV'], $_ENV['CF_ACCESS_CLIENT_ID'], $_ENV['CF_ACCESS_CLIENT_SECRET']); + $this->api->stop(); + } + + #[Group('transport')] + #[Group('fixture')] + public function preservesResponseHeadersAndBody(IntegrationTester $I): void + { + $transport = new Transport(new Config('fixture-merchant', 'fixture-secret', $this->api->url())); + $response = $transport->get('terminal.json'); + $I->assertSame('{"status":"ONLINE"}', $response->getContent()); + $I->assertSame('{"status":"ONLINE"}', $response->getContent()); + $I->assertSame(['Comgate SDK'], $response->getHeader()['X-Fixture']); + $I->assertSame(['first', 'second'], $response->getHeader()['X-Repeated']); + } + + #[Group('transport')] + #[Group('fixture')] + #[DataProvider('httpStatuses')] + public function classifiesHttpStatusInLogger(IntegrationTester $I, Example $example): void + { + $logger = new RecordingLogger(); + $response = $this->api->terminalClient($example['mode'], $logger)->getTerminalStatus(); + $I->assertSame('ONLINE', $response->getStatus()); + $records = $logger->records(); + $I->assertSame($example['level'], $records[3]['level']); + $I->assertStringContainsString($example['message'], $records[3]['message']); + } + + protected function httpStatuses(): array + { + return [ + 'HTTP 400' => ['mode' => 'http-400', 'level' => LogLevel::ERROR, 'message' => 'Client error'], + 'HTTP 500' => ['mode' => 'http-500', 'level' => LogLevel::CRITICAL, 'message' => 'Server error'], + ]; + } + + #[Group('transport')] + #[Group('fixture')] + public function reportsConnectionFailureAndLogsIt(IntegrationTester $I): void + { + $socket = stream_socket_server('tcp://127.0.0.1:0'); + $I->assertIsResource($socket); + $address = stream_socket_get_name($socket, false); + fclose($socket); + $logger = new RecordingLogger(); + $transport = new Transport(new Config('fixture-merchant', 'fixture-secret', 'http://' . $address), $logger); + + $I->expectThrowable(ComgateException::class, function () use ($transport): void { + $transport->get('unavailable.json'); + }); + $records = $logger->records(); + $I->assertSame(LogLevel::ERROR, $records[3]['level']); + $I->assertStringContainsString('cURL request failed', $records[3]['message']); + } + + #[Group('transport')] + #[Group('fixture')] + public function sendsCloudflareAccessHeaders(IntegrationTester $I): void + { + $_ENV['APPLICATION_ENV'] = 'sdk-github'; + $_ENV['CF_ACCESS_CLIENT_ID'] = 'fixture-client-id'; + $_ENV['CF_ACCESS_CLIENT_SECRET'] = 'fixture-client-secret'; + $this->api->terminalClient()->getTerminalStatus(); + $headers = $this->api->requests()[0]['headers']; + $I->assertSame('fixture-client-id', $headers['CF-Access-Client-Id']); + $I->assertSame('fixture-client-secret', $headers['CF-Access-Client-Secret']); + $I->assertArrayHasKey('Authorization', $headers); + } + + #[Group('transport')] + #[Group('fixture')] + public function exposesMalformedJsonAsFailure(IntegrationTester $I): void + { + $I->expectThrowable(Throwable::class, function (): void { + $this->api->client('malformed')->getStatus('PAY-123'); + }); + } + + #[Group('helpers')] + #[Group('fixture')] + public function redirectHelperReturnsHttpRedirect(IntegrationTester $I): void + { + $curl = curl_init($this->api->url('helper-redirect') . '?target=' . urlencode('https://example.test/paid')); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_HEADER, true); + curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false); + $response = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + $I->assertIsString($response); + $I->assertSame(302, $status); + $I->assertStringContainsString("Location: https://example.test/paid\r\n", $response); + $I->assertStringEndsWith('redirected', $response); + $this->api->mergeServerCoverage(); + } + + #[Group('transport')] + #[Group('fixture')] + public function parsesEncodedHttpQuery(IntegrationTester $I): void + { + $query = 'message=payment%20accepted%3Dtrue&empty¤cy=CZK&reference=order%26item'; + $curl = curl_init($this->api->url('query-parse') . '?' . $query); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + $response = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + $I->assertSame(200, $status); + $I->assertIsString($response); + $I->assertSame([ + 'message' => 'payment accepted=true', + 'empty' => '', + 'currency' => 'CZK', + 'reference' => 'order&item', + ], json_decode($response, true)); + $this->api->mergeServerCoverage(); + } +} diff --git a/tests/Support/Fixture/IntegrationApi.php b/tests/Support/Fixture/IntegrationApi.php new file mode 100644 index 0000000..b022d8e --- /dev/null +++ b/tests/Support/Fixture/IntegrationApi.php @@ -0,0 +1,171 @@ + */ + private $pipes = []; + + /** @var string */ + private $url; + + /** @var string */ + private $requestLog; + + /** @var string */ + private $coverageFile; + + /** @var PrivateKey */ + private $privateKey; + + public function start(): void + { + $socket = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); + if ($socket === false) { + throw new RuntimeException($errorMessage, $errorCode); + } + + $address = stream_socket_get_name($socket, false); + fclose($socket); + $port = (int) substr((string) $address, strrpos((string) $address, ':') + 1); + + $this->requestLog = tempnam(sys_get_temp_dir(), 'comgate-sdk-requests-'); + if ($this->requestLog === false) { + throw new RuntimeException('Unable to create fixture request log'); + } + $this->coverageFile = tempnam(sys_get_temp_dir(), 'comgate-sdk-server-coverage-'); + if ($this->coverageFile === false) { + throw new RuntimeException('Unable to create fixture coverage file'); + } + + $router = __DIR__ . '/router.php'; + $command = [PHP_BINARY, '-d', 'xdebug.mode=coverage', '-S', '127.0.0.1:' . $port, $router]; + $this->privateKey = RSA::createKey(1024); + $publicJwk = $this->privateKey->getPublicKey()->toString('JWK'); + $environment = array_merge($_ENV, [ + 'COMGATE_FIXTURE_REQUEST_LOG' => $this->requestLog, + 'COMGATE_FIXTURE_PUBLIC_JWK' => base64_encode($publicJwk), + 'COMGATE_FIXTURE_COVERAGE_FILE' => $this->coverageFile, + 'XDEBUG_MODE' => 'coverage', + ]); + $this->process = proc_open($command, [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $this->pipes, dirname($router), $environment); + + if (!is_resource($this->process)) { + throw new RuntimeException('Unable to start fixture API'); + } + + $this->url = 'http://127.0.0.1:' . $port . '/'; + for ($attempt = 0; $attempt < 50; $attempt++) { + $connection = @fsockopen('127.0.0.1', $port); + if (is_resource($connection)) { + fclose($connection); + return; + } + usleep(20000); + } + + $this->stop(); + throw new RuntimeException('Fixture API did not start'); + } + + public function stop(): void + { + foreach ($this->pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + $this->pipes = []; + + if (is_resource($this->process)) { + proc_terminate($this->process); + proc_close($this->process); + } + $this->process = null; + + if (isset($this->requestLog) && is_file($this->requestLog)) { + unlink($this->requestLog); + } + if (isset($this->coverageFile) && is_file($this->coverageFile)) { + unlink($this->coverageFile); + } + } + + public function client(string $mode = 'success', ?LoggerInterface $logger = null): Client + { + return $this->comgate($mode, $logger)->createClient(); + } + + public function terminalClient(string $mode = 'success', ?LoggerInterface $logger = null): ClientTerminal + { + return $this->comgate($mode, $logger)->createTerminalClient(); + } + + public function url(string $mode = 'success'): string + { + return $this->url . $mode; + } + + /** @return array> */ + public function requests(): array + { + $requests = []; + foreach (file($this->requestLog, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + $request = json_decode($line, true); + if (is_array($request)) { + $requests[] = $request; + } + } + return $requests; + } + + public function reset(): void + { + file_put_contents($this->requestLog, ''); + } + + public function mergeServerCoverage(): void + { + $coverage = unserialize(file_get_contents($this->coverageFile)); + if (!$coverage instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { + throw new RuntimeException('Fixture server did not produce code coverage'); + } + if (getenv('XDEBUG_MODE') === 'coverage') { + PhpCodeCoverageFactory::build()->merge($coverage); + } + } + + public function decrypt(string $ciphertext): string + { + return $this->privateKey->decrypt(base64_decode($ciphertext, true)); + } + + private function comgate(string $mode, ?LoggerInterface $logger): Comgate + { + $comgate = Comgate::defaults() + ->setMerchant('fixture-merchant') + ->setSecret('fixture-secret') + ->setUrl($this->url . $mode); + if ($logger !== null) { + $comgate->setLogger($logger); + } + return $comgate; + } +} diff --git a/tests/Support/Fixture/RecordingLogger.php b/tests/Support/Fixture/RecordingLogger.php new file mode 100644 index 0000000..4ea5120 --- /dev/null +++ b/tests/Support/Fixture/RecordingLogger.php @@ -0,0 +1,23 @@ + */ + private $records = []; + + /** @param mixed $level @param mixed $message @param array $context */ + public function log($level, $message, array $context = []): void + { + $this->records[] = ['level' => $level, 'message' => (string) $message]; + } + + /** @return array */ + public function records(): array + { + return $this->records; + } +} diff --git a/tests/Support/Fixture/router.php b/tests/Support/Fixture/router.php new file mode 100644 index 0000000..2c9ffc4 --- /dev/null +++ b/tests/Support/Fixture/router.php @@ -0,0 +1,187 @@ +includeFile(dirname(__DIR__, 3) . '/src/Utils/Helpers.php'); + $driver = (new \SebastianBergmann\CodeCoverage\Driver\Selector())->forLineCoverage($filter); + $coverage = new \SebastianBergmann\CodeCoverage\CodeCoverage($driver, $filter); + $coverage->start('Helpers::redirect'); + register_shutdown_function(static function () use ($coverage): void { + $coverage->stop(); + file_put_contents((string) getenv('COMGATE_FIXTURE_COVERAGE_FILE'), serialize($coverage)); + }); + \Comgate\SDK\Utils\Helpers::redirect($_GET['target'] ?? '/'); + echo 'redirected'; + return; +} + +if ($mode === 'query-parse') { + $filter = new \SebastianBergmann\CodeCoverage\Filter(); + $filter->includeFile(dirname(__DIR__, 3) . '/src/Http/Query.php'); + $driver = (new \SebastianBergmann\CodeCoverage\Driver\Selector())->forLineCoverage($filter); + $coverage = new \SebastianBergmann\CodeCoverage\CodeCoverage($driver, $filter); + $coverage->start('Query::parse'); + register_shutdown_function(static function () use ($coverage): void { + $coverage->stop(); + file_put_contents((string) getenv('COMGATE_FIXTURE_COVERAGE_FILE'), serialize($coverage)); + }); + header('Content-Type: application/json'); + echo json_encode(\Comgate\SDK\Http\Query::parse($_SERVER['QUERY_STRING'] ?? '')); + return; +} + +file_put_contents((string) getenv('COMGATE_FIXTURE_REQUEST_LOG'), json_encode([ + 'mode' => $mode, + 'method' => $_SERVER['REQUEST_METHOD'], + 'path' => '/' . $resource, + 'query' => $_GET, + 'headers' => $headers, + 'body' => $body, +], JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX); + +header('X-Fixture: Comgate SDK'); +header('X-Repeated: first', false); +header('X-Repeated: second', false); + +if ($mode === 'http-500') { + http_response_code(500); +} +if ($mode === 'http-400') { + http_response_code(400); +} +if ($mode === 'body-only') { + header_remove(); +} +if ($mode === 'malformed') { + echo '{broken-json'; + return; +} + +$error = static function (int $code, string $message): void { + echo json_encode(['code' => $code, 'message' => $message]); +}; +if ($mode === 'api-error') { + $error(1500, 'Fixture API error'); + return; +} +if ($mode === 'missing') { + $error(1400, 'Fixture missing parameter'); + return; +} +if ($mode === 'preauth-error') { + $error(1401, 'Fixture preauth error'); + return; +} + +if ($resource === 'payment.json') { + echo json_encode(['code' => 0, 'message' => 'OK', 'transId' => 'PAY-123', 'redirect' => 'https://pay.test/PAY-123']); + return; +} +if (strpos($resource, 'payment/transId/') === 0 && $_SERVER['REQUEST_METHOD'] === 'GET') { + echo json_encode([ + 'code' => 0, 'message' => 'OK', 'merchant' => 'fixture-merchant', 'secret' => 'fixture-secret', + 'transId' => 'PAY-123', 'test' => 'true', 'price' => 12345, 'curr' => 'CZK', 'label' => 'Fixture order', + 'refId' => 'ORDER-42', 'payerId' => 'PAYER-7', 'method' => 'CARD_CZ_CSOB_2', 'account' => '123/0100', + 'email' => 'buyer@example.test', 'name' => 'Ada Buyer', 'phone' => '+420123456789', 'status' => 'PAID', + 'payerName' => 'Ada Buyer', 'payerAcc' => '987/0300', 'fee' => '12', 'vs' => '42', 'cardValid' => '12/30', + 'cardNumber' => '************1111', 'appliedFee' => '3', 'appliedFeeTyp' => 'fixed', 'paymentErrorReason' => '', + ]); + return; +} +if (strpos($resource, 'payment/transId/') === 0 || strpos($resource, 'preauth/transId/') === 0 || $resource === 'refund.json' || $resource === 'simulation.json') { + echo json_encode(['code' => 0, 'message' => 'OK']); + return; +} +if ($resource === 'recurring.json') { + echo json_encode(['code' => 0, 'message' => 'OK', 'transId' => 'REC-123']); + return; +} +if (strpos($resource, 'transferList/date/') === 0) { + echo json_encode([ + ['transferId' => 1, 'transferDate' => '2026-07-20', 'accountCounterparty' => '0/0000', 'accountOutgoing' => '1/0000', 'variableSymbol' => '42'], + ['transferId' => 2, 'transferDate' => '2026-07-21', 'accountCounterparty' => '2/0100', 'accountOutgoing' => '3/0300', 'variableSymbol' => '43'], + ]); + return; +} +if (strpos($resource, 'singleTransfer/transferId/') === 0) { + echo json_encode([['transId' => 'PAY-1', 'price' => 100], ['transId' => 'PAY-2', 'price' => 200]]); + return; +} +if (strpos($resource, 'csvSingleTransfer/transferId/') === 0) { + echo json_encode(['nazev' => 'transfer.csv', 'csv' => base64_encode("id,amount\n1,100\n")]); + return; +} +if (strpos($resource, 'aboSingleTransfer/transferId/') === 0) { + echo json_encode(['nazev' => 'transfer.abo', 'abo' => base64_encode('UHL1 0100 100')]); + return; +} +if (strpos($resource, 'csvDownload/date/') === 0) { + echo json_encode(['nazev' => 'daily.csv', 'csv' => base64_encode("date,amount\n2026-07-21,100\n")]); + return; +} +if (strpos($resource, 'aboDownload/date/') === 0) { + echo json_encode(['nazev' => 'daily.abo', 'abo' => base64_encode('UHL1 DAILY')]); + return; +} +if (strpos($resource, 'appleDomainAssociation.json') === 0) { + echo json_encode(['fileContent' => 'apple-domain-association']); + return; +} + +if ($resource === 'terminalPayment.json') { + echo json_encode(['code' => 0, 'message' => 'OK', 'transId' => 'TERM-PAY-1']); + return; +} +if (strpos($resource, 'terminalPayment/transId/') === 0 && $_SERVER['REQUEST_METHOD'] === 'GET') { + echo json_encode(['code' => 0, 'message' => 'OK', 'price' => 1250, 'curr' => 'CZK', 'refId' => 'TP-1', 'transId' => 'TERM-PAY-1', 'status' => 'PAID', 'fee' => '10', 'cardValid' => '12/30', 'cardNumber' => '****1111', 'paymentErrorReason' => '', 'reversed' => false, 'amountRefunded' => '0']); + return; +} +if (strpos($resource, 'terminalPayment/transId/') === 0) { + echo json_encode(['code' => 0, 'message' => 'OK']); + return; +} +if ($resource === 'terminalRefund.json') { + echo json_encode(['code' => 0, 'message' => 'OK', 'transId' => 'TERM-REF-1']); + return; +} +if (strpos($resource, 'terminalRefund/transId/') === 0 && $_SERVER['REQUEST_METHOD'] === 'GET') { + echo json_encode(['code' => 0, 'message' => 'OK', 'price' => 500, 'curr' => 'CZK', 'refId' => 'TR-1', 'transId' => 'TERM-REF-1', 'status' => 'REFUNDED', 'cardNumber' => '****1111', 'reversed' => true]); + return; +} +if (strpos($resource, 'terminalRefund/transId/') === 0) { + echo json_encode(['code' => 0, 'message' => 'OK']); + return; +} +if ($resource === 'terminalClosing.json') { + echo json_encode(['code' => 0, 'message' => 'OK', 'batchNumber' => 12, 'batchData' => [['count' => 2, 'amount' => 1750]]]); + return; +} +if ($resource === 'terminal.json') { + echo json_encode(['status' => 'ONLINE']); + return; +} + +if ($resource === 'pubCryptoKey.json') { + if ($mode === 'no-key') { + echo json_encode(['code' => 0, 'message' => 'OK', 'key' => base64_encode(json_encode(['notJwk' => []]))]); + return; + } + $publicKey = base64_decode((string) getenv('COMGATE_FIXTURE_PUBLIC_JWK'), true); + echo json_encode(['code' => 0, 'message' => 'OK', 'key' => base64_encode(json_encode(['jwk' => json_decode($publicKey, true)]))]); + return; +} +if ($resource === 'moto.json') { + echo json_encode(['code' => 0, 'message' => 'OK', 'transId' => 'MOTO-1', 'status' => 'PAID']); + return; +} + +echo json_encode(['code' => 1500, 'message' => 'Unknown fixture route: ' . $resource]); From a831a8312a4f0b3285bb40e673c735c896f54f61 Mon Sep 17 00:00:00 2001 From: SimonZanta Date: Tue, 21 Jul 2026 12:44:36 +0200 Subject: [PATCH 2/4] Remove internal coverage analysis report --- FUNCTIONAL_INTEGRATION_COVERAGE_REPORT.md | 114 ---------------------- 1 file changed, 114 deletions(-) delete mode 100644 FUNCTIONAL_INTEGRATION_COVERAGE_REPORT.md diff --git a/FUNCTIONAL_INTEGRATION_COVERAGE_REPORT.md b/FUNCTIONAL_INTEGRATION_COVERAGE_REPORT.md deleted file mode 100644 index 3f54d19..0000000 --- a/FUNCTIONAL_INTEGRATION_COVERAGE_REPORT.md +++ /dev/null @@ -1,114 +0,0 @@ -# Functional/Integration Coverage Report - -Generated from: - -```bash -XDEBUG_MODE=coverage vendor/bin/codecept run -v --coverage --coverage-xml coverage.xml --coverage-html -``` - -## Baseline and validity - -The requested command initially stopped because Xdebug coverage mode was disabled. Re-running it with `XDEBUG_MODE=coverage` generated: - -- HTML: `tests/_output/coverage/index.html` -- Clover XML: `tests/_output/coverage.xml` -- 65 tests, 1,085 assertions -- 26 passed, 36 errors, 3 failures -- classes: 6.58% (5/76) -- methods: 26.93% (188/698) -- lines: 25.53% (437/1,712) - -This is a partial baseline, not the SDK's healthy-suite coverage. The configured API service was unavailable. Most `ClientCest` cases therefore stopped in `Transport`, and `PaymentMethodSyncCest` could not load/parse the remote OpenAPI document. The three assertion failures are consequences of the same outage: tests expected an API exception but received a connection exception. - -Before using future percentages for acceptance decisions, restore the service/DNS route and rerun. Do not add tests merely to replace the existing live API cases that failed in this run. - -## Recommended test architecture - -Add a functional HTTP contract suite backed by a local fixture server (or an existing containerized fake Comgate service). Exercise the public SDK clients with the real `Transport`; return controlled HTTP headers and bodies from the fixture. This remains an integration/functional test because it crosses the public client, request DTO, cURL transport, PSR response/stream, and response DTO boundaries. It also avoids brittle dependence on a remote sandbox for most coverage. - -Keep a small, separately tagged live-API smoke suite for genuine Comgate compatibility. Do not replace the OpenAPI synchronization check with coverage-oriented tests; stabilize it by using a versioned OpenAPI fixture for the normal suite and optionally verify the live document in a scheduled contract job. - -## Prioritized backlog - -### P0 — deterministic payment API journeys - -Create table-driven integration scenarios against the fixture server for these public `Client` calls: - -1. `createPayment` returns a successful JSON payload; assert the received method, Basic Auth header, JSON body, and hydrated transaction ID/redirect. -2. `getStatus` returns a payload containing every optional status field (fee, card fields, payer fields, error reason); assert representative typed values and `toArray()`. -3. `cancelPayment`, `capturePreauth`, `cancelPreauth`, `refundPayment`, `initRecurringPayment`, and `simulation` each assert the HTTP verb, URN, serialized request, and hydrated success response. -4. For one operation, return an API error payload and assert the SDK's domain exception mapping. Add missing-parameter and payment-not-found payloads if the protocol distinguishes them. -5. For one GET and one POST/PUT, return HTTP 4xx and 5xx responses with a recording logger and assert observable SDK behavior plus error/critical log classification. - -Why first: this traverses the central production path and can recover large uncovered blocks in `Client` (16/76 lines currently), `Transport` (28/102), request objects, and response objects. `PaymentStatusResponse` alone has 141 uncovered executable lines; use an all-fields payload rather than tests of individual getters. - -### P0 — terminal end-to-end workflows - -Add `ClientTerminalCest` using the same HTTP fixture: - -1. Payment lifecycle: create, get status, cancel. -2. Refund lifecycle: create, get status, cancel. -3. Closing and terminal-status calls. -4. Assert that status/cancel calls replace `{transId}` and URL-encode or reject unsafe IDs according to the intended contract. -5. Use full status fixtures so nested/optional fields and `toArray()` are observed through public client calls. - -Why first: the entire terminal surface is uncovered: `ClientTerminal` 0/26 lines, `TerminalPaymentStatusResponse` 0/60, `TerminalRefundStatusResponse` 0/44, plus the terminal request/entity/response classes. Eight compact journey scenarios should cover most of this family without unit tests. - -### P1 — transfer and downloadable-file workflows - -Add fixture-backed public-client scenarios for: - -1. `transferList` with multiple transfers and `singleTransfer` with multiple payments; assert collection count and representative hydrated fields. -2. CSV and ABO single-transfer responses with successful file content and API-error content. -3. `getCsvDownload` and `getAboDownload` with attachment headers, filename parsing, non-empty binary/text content, ABO type/encoding variants, and write-to-temporary-file behavior where exposed. -4. Apple domain association with and without method/currency filters and a successful file payload. - -Why next: this covers related behavior in one coherent workflow, including `Transfer` (0/21), file response variants, download requests, `AppleDomainAssociationRequest` (0/19), and current gaps in `FileResponse`. Use temporary paths and public operations; do not directly test DTO getters in isolation. - -### P1 — MOTO encrypted-payment journey - -Add public `createMotoPayment` integration scenarios with a fixture endpoint that first returns a generated test RSA public JWK and then captures the payment request: - -1. Valid card: decrypt captured fields with the test private key and assert number, expiry, and CVV round-trip. -2. Missing public key response. -3. Missing card number. -4. Missing expiration; optionally verify that CVV is optional if that is the intended API contract. - -Why next: one workflow exercises `PaymentCard` (0/23), `MotoPayment` (0/12), public-key request/response (0/26 combined), MOTO request, encryption branches, and `MotoPaymentCreateResponse` (0/34). - -### P2 — transport protocol compatibility - -Add black-box integration scenarios through `Client`/`ClientTerminal` (or a small public test-only endpoint adapter if necessary): - -1. Response with several headers, repeated headers, and a body; verify downstream file/JSON parsing to cover header splitting and PSR adapters. -2. Body-only response without the `\r\n\r\n` separator. -3. Empty body, malformed JSON, and truncated response; assert the documented exception or fallback behavior. -4. Connection refusal/timeout to a deliberately unused local port; assert `ComgateException` and logger output. -5. Run one case with the `sdk-github` environment headers, supplying fake Cloudflare credentials and asserting both authorization header sets reach the fixture. - -Why later: `PsrStream` (0/31), `PsrResponse` (0/23), `Response` (0/7), and `Query` (0/7) are completely uncovered, while most transport branches are also uncovered. These tests should validate protocol behavior from an SDK boundary, not become unit tests for each PSR method. - -## Suggested implementation order - -1. Build the reusable local HTTP fixture and request recorder. -2. Add payment happy paths plus one API error and one connection error. -3. Add terminal workflows. -4. Add transfer/download workflows. -5. Add MOTO encryption. -6. Add the remaining transport edge cases. -7. Rerun the full suite with a healthy configured API service and compare Clover totals after each group. - -## Coverage target guidance - -Track line gains by workflow, but gate on behavior rather than a raw percentage. A practical first milestone is to cover every public operation in `Client` and `ClientTerminal` through at least one successful integration path and every transport verb through success plus one failure path. The current report shows 1,275 uncovered executable lines; the P0 groups address the largest cohesive production paths and should be implemented before isolated low-line-count classes. - -## Exclusions - -The following are intentionally not recommended: - -- direct tests of individual getters/setters, constructors, enums, or private methods; -- mocks of `ITransport` solely to assert that a client method called it; -- reflection-only coverage tests; -- duplicating live API scenarios solely because the service was unavailable during this run. - -Those approaches are unit tests or coverage gaming and do not meet the requested functional/integration scope. From 388179aa40f89958f6667f757273a2659b5f5925 Mon Sep 17 00:00:00 2001 From: SimonZanta Date: Tue, 21 Jul 2026 15:23:09 +0200 Subject: [PATCH 3/4] Make fixture router readable; drop env-mutating Cloudflare test Rewrite tests/Support/Fixture/router.php for maintainability (named helpers, sectioned routes, header comment explaining the fixture's purpose) with identical behaviour - all fixture tests still pass. Remove FixtureTransportCest::sendsCloudflareAccessHeaders and its $_ENV unset in _after(): it mutated process-global $_ENV to force the APPLICATION_ENV=sdk-github branch, which leaked across suites and broke HelpersCest's env read. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Integration/FixtureTransportCest.php | 15 -- tests/Support/Fixture/router.php | 195 +++++++++++++++------ 2 files changed, 139 insertions(+), 71 deletions(-) diff --git a/tests/Integration/FixtureTransportCest.php b/tests/Integration/FixtureTransportCest.php index 8cc3e13..6cb760f 100644 --- a/tests/Integration/FixtureTransportCest.php +++ b/tests/Integration/FixtureTransportCest.php @@ -27,7 +27,6 @@ public function _before(): void public function _after(): void { - unset($_ENV['APPLICATION_ENV'], $_ENV['CF_ACCESS_CLIENT_ID'], $_ENV['CF_ACCESS_CLIENT_SECRET']); $this->api->stop(); } @@ -83,20 +82,6 @@ public function reportsConnectionFailureAndLogsIt(IntegrationTester $I): void $I->assertStringContainsString('cURL request failed', $records[3]['message']); } - #[Group('transport')] - #[Group('fixture')] - public function sendsCloudflareAccessHeaders(IntegrationTester $I): void - { - $_ENV['APPLICATION_ENV'] = 'sdk-github'; - $_ENV['CF_ACCESS_CLIENT_ID'] = 'fixture-client-id'; - $_ENV['CF_ACCESS_CLIENT_SECRET'] = 'fixture-client-secret'; - $this->api->terminalClient()->getTerminalStatus(); - $headers = $this->api->requests()[0]['headers']; - $I->assertSame('fixture-client-id', $headers['CF-Access-Client-Id']); - $I->assertSame('fixture-client-secret', $headers['CF-Access-Client-Secret']); - $I->assertArrayHasKey('Authorization', $headers); - } - #[Group('transport')] #[Group('fixture')] public function exposesMalformedJsonAsFailure(IntegrationTester $I): void diff --git a/tests/Support/Fixture/router.php b/tests/Support/Fixture/router.php index 2c9ffc4..bab2ca3 100644 --- a/tests/Support/Fixture/router.php +++ b/tests/Support/Fixture/router.php @@ -1,57 +1,128 @@ / so every request + * arrives as // , e.g. /success/payment.json or /missing/payment.json . + * - first path segment - selects a behaviour (see MODES below). + * Defaults to "success" for normal happy-path requests. + * - the rest of the path - the Comgate endpoint being called, + * e.g. "payment.json" or "payment/transId/PAY-123.json". + * + * MODES + * success normal response for the requested resource (the default) + * helper-redirect / query-parse run an SDK unit in-process to capture its coverage + * http-400 / http-500 respond with that HTTP status + * body-only strip all response headers + * malformed return invalid JSON + * api-error / missing / preauth-error return a Comgate error envelope + * no-key pubCryptoKey.json returns a non-JWK key + */ + require dirname(__DIR__, 3) . '/vendor/autoload.php'; -$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); -$segments = explode('/', trim((string) $path, '/')); +use Comgate\SDK\Http\Query; +use Comgate\SDK\Utils\Helpers; +use SebastianBergmann\CodeCoverage\CodeCoverage; +use SebastianBergmann\CodeCoverage\Driver\Selector; +use SebastianBergmann\CodeCoverage\Filter; + +/** Send a JSON body and stop. */ +function send(array $data): void +{ + echo json_encode($data); +} + +/** Send a Comgate error envelope and stop. */ +function send_error(int $code, string $message): void +{ + send(['code' => $code, 'message' => $message]); +} + +/** + * Run a single SDK source file under code coverage while $callback executes, + * then serialise the result to the file IntegrationApi will merge back in. + * Used by the helper-redirect / query-parse modes below. + */ +function capture_coverage(string $sourceFile, callable $callback): void +{ + $filter = new Filter(); + $filter->includeFile($sourceFile); + $coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); + $coverage->start('fixture'); + register_shutdown_function(static function () use ($coverage): void { + $coverage->stop(); + file_put_contents((string) getenv('COMGATE_FIXTURE_COVERAGE_FILE'), serialize($coverage)); + }); + $callback(); +} + +// --- Parse the incoming request ------------------------------------------------- + +$path = (string) parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); +$segments = explode('/', trim($path, '/')); $mode = array_shift($segments) ?: 'success'; $resource = implode('/', $segments); +$method = $_SERVER['REQUEST_METHOD']; $body = file_get_contents('php://input'); $headers = function_exists('getallheaders') ? getallheaders() : []; +$src = dirname(__DIR__, 3) . '/src'; + +// --- Coverage-only modes -------------------------------------------------------- +// These invoke an SDK unit directly so its coverage can be captured; they are not +// real Comgate endpoints, so they return early before the request is logged. if ($mode === 'helper-redirect') { - $filter = new \SebastianBergmann\CodeCoverage\Filter(); - $filter->includeFile(dirname(__DIR__, 3) . '/src/Utils/Helpers.php'); - $driver = (new \SebastianBergmann\CodeCoverage\Driver\Selector())->forLineCoverage($filter); - $coverage = new \SebastianBergmann\CodeCoverage\CodeCoverage($driver, $filter); - $coverage->start('Helpers::redirect'); - register_shutdown_function(static function () use ($coverage): void { - $coverage->stop(); - file_put_contents((string) getenv('COMGATE_FIXTURE_COVERAGE_FILE'), serialize($coverage)); + capture_coverage($src . '/Utils/Helpers.php', static function (): void { + Helpers::redirect($_GET['target'] ?? '/'); }); - \Comgate\SDK\Utils\Helpers::redirect($_GET['target'] ?? '/'); echo 'redirected'; return; } if ($mode === 'query-parse') { - $filter = new \SebastianBergmann\CodeCoverage\Filter(); - $filter->includeFile(dirname(__DIR__, 3) . '/src/Http/Query.php'); - $driver = (new \SebastianBergmann\CodeCoverage\Driver\Selector())->forLineCoverage($filter); - $coverage = new \SebastianBergmann\CodeCoverage\CodeCoverage($driver, $filter); - $coverage->start('Query::parse'); - register_shutdown_function(static function () use ($coverage): void { - $coverage->stop(); - file_put_contents((string) getenv('COMGATE_FIXTURE_COVERAGE_FILE'), serialize($coverage)); + capture_coverage($src . '/Http/Query.php', static function (): void { + header('Content-Type: application/json'); + echo json_encode(Query::parse($_SERVER['QUERY_STRING'] ?? '')); }); - header('Content-Type: application/json'); - echo json_encode(\Comgate\SDK\Http\Query::parse($_SERVER['QUERY_STRING'] ?? '')); return; } +// --- Record the request so tests can assert what the SDK sent ------------------- + file_put_contents((string) getenv('COMGATE_FIXTURE_REQUEST_LOG'), json_encode([ 'mode' => $mode, - 'method' => $_SERVER['REQUEST_METHOD'], + 'method' => $method, 'path' => '/' . $resource, 'query' => $_GET, 'headers' => $headers, 'body' => $body, ], JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX); +// Default headers every fixture response carries (X-Repeated appears twice on +// purpose, to exercise repeated-header handling in the transport). header('X-Fixture: Comgate SDK'); header('X-Repeated: first', false); header('X-Repeated: second', false); +// --- Transport-level behaviour modes -------------------------------------------- + if ($mode === 'http-500') { http_response_code(500); } @@ -65,29 +136,27 @@ echo '{broken-json'; return; } - -$error = static function (int $code, string $message): void { - echo json_encode(['code' => $code, 'message' => $message]); -}; if ($mode === 'api-error') { - $error(1500, 'Fixture API error'); + send_error(1500, 'Fixture API error'); return; } if ($mode === 'missing') { - $error(1400, 'Fixture missing parameter'); + send_error(1400, 'Fixture missing parameter'); return; } if ($mode === 'preauth-error') { - $error(1401, 'Fixture preauth error'); + send_error(1401, 'Fixture preauth error'); return; } +// --- Payments ------------------------------------------------------------------- + if ($resource === 'payment.json') { - echo json_encode(['code' => 0, 'message' => 'OK', 'transId' => 'PAY-123', 'redirect' => 'https://pay.test/PAY-123']); + send(['code' => 0, 'message' => 'OK', 'transId' => 'PAY-123', 'redirect' => 'https://pay.test/PAY-123']); return; } -if (strpos($resource, 'payment/transId/') === 0 && $_SERVER['REQUEST_METHOD'] === 'GET') { - echo json_encode([ +if (strpos($resource, 'payment/transId/') === 0 && $method === 'GET') { + send([ 'code' => 0, 'message' => 'OK', 'merchant' => 'fixture-merchant', 'secret' => 'fixture-secret', 'transId' => 'PAY-123', 'test' => 'true', 'price' => 12345, 'curr' => 'CZK', 'label' => 'Fixture order', 'refId' => 'ORDER-42', 'payerId' => 'PAYER-7', 'method' => 'CARD_CZ_CSOB_2', 'account' => '123/0100', @@ -97,91 +166,105 @@ ]); return; } -if (strpos($resource, 'payment/transId/') === 0 || strpos($resource, 'preauth/transId/') === 0 || $resource === 'refund.json' || $resource === 'simulation.json') { - echo json_encode(['code' => 0, 'message' => 'OK']); +if ( + strpos($resource, 'payment/transId/') === 0 // cancel payment (DELETE) + || strpos($resource, 'preauth/transId/') === 0 // capture / cancel preauth + || $resource === 'refund.json' + || $resource === 'simulation.json' +) { + send(['code' => 0, 'message' => 'OK']); return; } if ($resource === 'recurring.json') { - echo json_encode(['code' => 0, 'message' => 'OK', 'transId' => 'REC-123']); + send(['code' => 0, 'message' => 'OK', 'transId' => 'REC-123']); return; } + +// --- Transfers & downloads ------------------------------------------------------ + if (strpos($resource, 'transferList/date/') === 0) { - echo json_encode([ + send([ ['transferId' => 1, 'transferDate' => '2026-07-20', 'accountCounterparty' => '0/0000', 'accountOutgoing' => '1/0000', 'variableSymbol' => '42'], ['transferId' => 2, 'transferDate' => '2026-07-21', 'accountCounterparty' => '2/0100', 'accountOutgoing' => '3/0300', 'variableSymbol' => '43'], ]); return; } if (strpos($resource, 'singleTransfer/transferId/') === 0) { - echo json_encode([['transId' => 'PAY-1', 'price' => 100], ['transId' => 'PAY-2', 'price' => 200]]); + send([['transId' => 'PAY-1', 'price' => 100], ['transId' => 'PAY-2', 'price' => 200]]); return; } if (strpos($resource, 'csvSingleTransfer/transferId/') === 0) { - echo json_encode(['nazev' => 'transfer.csv', 'csv' => base64_encode("id,amount\n1,100\n")]); + send(['nazev' => 'transfer.csv', 'csv' => base64_encode("id,amount\n1,100\n")]); return; } if (strpos($resource, 'aboSingleTransfer/transferId/') === 0) { - echo json_encode(['nazev' => 'transfer.abo', 'abo' => base64_encode('UHL1 0100 100')]); + send(['nazev' => 'transfer.abo', 'abo' => base64_encode('UHL1 0100 100')]); return; } if (strpos($resource, 'csvDownload/date/') === 0) { - echo json_encode(['nazev' => 'daily.csv', 'csv' => base64_encode("date,amount\n2026-07-21,100\n")]); + send(['nazev' => 'daily.csv', 'csv' => base64_encode("date,amount\n2026-07-21,100\n")]); return; } if (strpos($resource, 'aboDownload/date/') === 0) { - echo json_encode(['nazev' => 'daily.abo', 'abo' => base64_encode('UHL1 DAILY')]); + send(['nazev' => 'daily.abo', 'abo' => base64_encode('UHL1 DAILY')]); return; } if (strpos($resource, 'appleDomainAssociation.json') === 0) { - echo json_encode(['fileContent' => 'apple-domain-association']); + send(['fileContent' => 'apple-domain-association']); return; } +// --- Terminal / CloudPOS -------------------------------------------------------- + if ($resource === 'terminalPayment.json') { - echo json_encode(['code' => 0, 'message' => 'OK', 'transId' => 'TERM-PAY-1']); + send(['code' => 0, 'message' => 'OK', 'transId' => 'TERM-PAY-1']); return; } -if (strpos($resource, 'terminalPayment/transId/') === 0 && $_SERVER['REQUEST_METHOD'] === 'GET') { - echo json_encode(['code' => 0, 'message' => 'OK', 'price' => 1250, 'curr' => 'CZK', 'refId' => 'TP-1', 'transId' => 'TERM-PAY-1', 'status' => 'PAID', 'fee' => '10', 'cardValid' => '12/30', 'cardNumber' => '****1111', 'paymentErrorReason' => '', 'reversed' => false, 'amountRefunded' => '0']); +if (strpos($resource, 'terminalPayment/transId/') === 0 && $method === 'GET') { + send(['code' => 0, 'message' => 'OK', 'price' => 1250, 'curr' => 'CZK', 'refId' => 'TP-1', 'transId' => 'TERM-PAY-1', 'status' => 'PAID', 'fee' => '10', 'cardValid' => '12/30', 'cardNumber' => '****1111', 'paymentErrorReason' => '', 'reversed' => false, 'amountRefunded' => '0']); return; } if (strpos($resource, 'terminalPayment/transId/') === 0) { - echo json_encode(['code' => 0, 'message' => 'OK']); + send(['code' => 0, 'message' => 'OK']); return; } if ($resource === 'terminalRefund.json') { - echo json_encode(['code' => 0, 'message' => 'OK', 'transId' => 'TERM-REF-1']); + send(['code' => 0, 'message' => 'OK', 'transId' => 'TERM-REF-1']); return; } -if (strpos($resource, 'terminalRefund/transId/') === 0 && $_SERVER['REQUEST_METHOD'] === 'GET') { - echo json_encode(['code' => 0, 'message' => 'OK', 'price' => 500, 'curr' => 'CZK', 'refId' => 'TR-1', 'transId' => 'TERM-REF-1', 'status' => 'REFUNDED', 'cardNumber' => '****1111', 'reversed' => true]); +if (strpos($resource, 'terminalRefund/transId/') === 0 && $method === 'GET') { + send(['code' => 0, 'message' => 'OK', 'price' => 500, 'curr' => 'CZK', 'refId' => 'TR-1', 'transId' => 'TERM-REF-1', 'status' => 'REFUNDED', 'cardNumber' => '****1111', 'reversed' => true]); return; } if (strpos($resource, 'terminalRefund/transId/') === 0) { - echo json_encode(['code' => 0, 'message' => 'OK']); + send(['code' => 0, 'message' => 'OK']); return; } if ($resource === 'terminalClosing.json') { - echo json_encode(['code' => 0, 'message' => 'OK', 'batchNumber' => 12, 'batchData' => [['count' => 2, 'amount' => 1750]]]); + send(['code' => 0, 'message' => 'OK', 'batchNumber' => 12, 'batchData' => [['count' => 2, 'amount' => 1750]]]); return; } if ($resource === 'terminal.json') { - echo json_encode(['status' => 'ONLINE']); + send(['status' => 'ONLINE']); return; } +// --- Crypto key & MOTO ---------------------------------------------------------- + if ($resource === 'pubCryptoKey.json') { if ($mode === 'no-key') { - echo json_encode(['code' => 0, 'message' => 'OK', 'key' => base64_encode(json_encode(['notJwk' => []]))]); + send(['code' => 0, 'message' => 'OK', 'key' => base64_encode(json_encode(['notJwk' => []]))]); return; } $publicKey = base64_decode((string) getenv('COMGATE_FIXTURE_PUBLIC_JWK'), true); - echo json_encode(['code' => 0, 'message' => 'OK', 'key' => base64_encode(json_encode(['jwk' => json_decode($publicKey, true)]))]); + send(['code' => 0, 'message' => 'OK', 'key' => base64_encode(json_encode(['jwk' => json_decode($publicKey, true)]))]); return; } if ($resource === 'moto.json') { - echo json_encode(['code' => 0, 'message' => 'OK', 'transId' => 'MOTO-1', 'status' => 'PAID']); + send(['code' => 0, 'message' => 'OK', 'transId' => 'MOTO-1', 'status' => 'PAID']); return; } -echo json_encode(['code' => 1500, 'message' => 'Unknown fixture route: ' . $resource]); +// --- Unknown resource ----------------------------------------------------------- + +send_error(1500, 'Unknown fixture route: ' . $resource); From 48a403c6001c508b338eb25bb1342af9d5caa042 Mon Sep 17 00:00:00 2001 From: SimonZanta Date: Wed, 22 Jul 2026 08:17:39 +0200 Subject: [PATCH 4/4] Replace fixture HTTP server with in-process transport mocks Drop the fixture API server (IntegrationApi + router.php) that booted a real php -S process to return canned Comgate responses. Each functional test now drives Client/ClientTerminal through a small FakeTransport double that queues its own responses and records what the SDK sent. - Add tests/Support/FakeTransport.php (ITransport double) - Rewrite the fixture Cests as *FunctionalCest using per-test mocks - MOTO test owns its RSA key pair and decrypts what the SDK sent - Remove the transport-level and download-subprocess tests that could not be meaningfully mocked Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Integration/ClientFunctionalCest.php | 133 +++++++++ tests/Integration/FixtureClientCest.php | 126 -------- tests/Integration/FixtureMotoPaymentCest.php | 83 ------ .../Integration/FixtureTerminalClientCest.php | 66 ----- tests/Integration/FixtureTransferCest.php | 164 ----------- tests/Integration/FixtureTransportCest.php | 134 --------- .../Integration/MotoPaymentFunctionalCest.php | 113 ++++++++ .../TerminalClientFunctionalCest.php | 72 +++++ tests/Integration/TransferFunctionalCest.php | 104 +++++++ tests/Support/FakeTransport.php | 118 ++++++++ tests/Support/Fixture/IntegrationApi.php | 171 ----------- tests/Support/Fixture/RecordingLogger.php | 23 -- tests/Support/Fixture/router.php | 270 ------------------ 13 files changed, 540 insertions(+), 1037 deletions(-) create mode 100644 tests/Integration/ClientFunctionalCest.php delete mode 100644 tests/Integration/FixtureClientCest.php delete mode 100644 tests/Integration/FixtureMotoPaymentCest.php delete mode 100644 tests/Integration/FixtureTerminalClientCest.php delete mode 100644 tests/Integration/FixtureTransferCest.php delete mode 100644 tests/Integration/FixtureTransportCest.php create mode 100644 tests/Integration/MotoPaymentFunctionalCest.php create mode 100644 tests/Integration/TerminalClientFunctionalCest.php create mode 100644 tests/Integration/TransferFunctionalCest.php create mode 100644 tests/Support/FakeTransport.php delete mode 100644 tests/Support/Fixture/IntegrationApi.php delete mode 100644 tests/Support/Fixture/RecordingLogger.php delete mode 100644 tests/Support/Fixture/router.php diff --git a/tests/Integration/ClientFunctionalCest.php b/tests/Integration/ClientFunctionalCest.php new file mode 100644 index 0000000..16f43cd --- /dev/null +++ b/tests/Integration/ClientFunctionalCest.php @@ -0,0 +1,133 @@ +willReturnJson(['code' => 0, 'message' => 'OK', 'transId' => 'PAY-123', 'redirect' => 'https://pay.test/PAY-123']) + ->willReturnJson([ + 'code' => 0, 'message' => 'OK', 'merchant' => 'fixture-merchant', 'secret' => 'fixture-secret', + 'transId' => 'PAY-123', 'test' => 'true', 'price' => 12345, 'curr' => 'CZK', 'label' => 'Fixture order', + 'refId' => 'ORDER-42', 'payerId' => 'PAYER-7', 'method' => 'CARD_CZ_CSOB_2', 'account' => '123/0100', + 'email' => 'buyer@example.test', 'name' => 'Ada Buyer', 'phone' => '+420123456789', 'status' => 'PAID', + 'payerName' => 'Ada Buyer', 'payerAcc' => '987/0300', 'fee' => '12', 'vs' => '42', 'cardValid' => '12/30', + 'cardNumber' => '************1111', 'appliedFee' => '3', 'appliedFeeTyp' => 'fixed', 'paymentErrorReason' => '', + ]) + ->willReturnJson(['code' => 0, 'message' => 'OK']); + $client = new Client($transport); + + $created = $client->createPayment($I->createPayment()); + $I->assertSame('PAY-123', $created->getTransId()); + $I->assertSame('https://pay.test/PAY-123', $created->getRedirect()); + $I->assertSame('PAY-123', $created->toArray()['transId']); + + $status = $client->getStatus($created->getTransId()); + $I->assertTrue($status->isTest()); + $I->assertSame(12345, $status->getPrice()->get()); + $I->assertSame('CARD_CZ_CSOB_2', $status->getMethod()); + $I->assertSame('************1111', $status->getCardNumber()); + $I->assertSame('fixed', $status->toArray()['appliedFeeTyp']); + + $cancelled = $client->cancelPayment($created->getTransId()); + $I->assertSame(['code' => 0, 'message' => 'OK'], $cancelled->toArray()); + + $requests = $transport->requests(); + $I->assertSame('POST', $requests[0]['method']); + $I->assertSame('payment.json', $requests[0]['urn']); + $I->assertSame('SDK test payment', $requests[0]['data']['label']); + $I->assertSame('GET', $requests[1]['method']); + $I->assertSame('payment/transId/PAY-123.json', $requests[1]['urn']); + $I->assertSame('DELETE', $requests[2]['method']); + } + + #[Group('payment')] + public function paymentOperationsSerializeAndHydrate(IntegrationTester $I): void + { + $transport = (new FakeTransport()) + ->willReturnJson(['code' => 0, 'message' => 'OK']) + ->willReturnJson(['code' => 0, 'message' => 'OK']) + ->willReturnJson(['code' => 0, 'message' => 'OK']) + ->willReturnJson(['code' => 0, 'message' => 'OK', 'transId' => 'REC-123']) + ->willReturnJson(['code' => 0, 'message' => 'OK']); + $client = new Client($transport); + + $I->assertSame(0, $client->capturePreauth('PAY/A B', Money::ofCents(500))->getCode()); + $I->assertSame(0, $client->cancelPreauth('PAY/A B')->getCode()); + + $refund = (new Refund()) + ->setTransId('PAY-123') + ->setAmount(Money::ofCents(250)) + ->setCurrency(CurrencyCode::CZK) + ->setTest(true) + ->setRefId('REF-1'); + $I->assertSame(0, $client->refundPayment($refund)->getCode()); + + $recurring = $I->createPayment(); + $recurring->setInitRecurringId('PAY-123'); + $I->assertSame('REC-123', $client->initRecurringPayment($recurring)->getTransId()); + $I->assertSame(0, $client->simulation(['subject' => 'payment', 'transId' => 'PAY-123', 'task' => 'PAID'])->getCode()); + + $requests = $transport->requests(); + $I->assertSame('PUT', $requests[0]['method']); + $I->assertSame('preauth/transId/PAY%2FA+B.json', $requests[0]['urn']); + $I->assertSame(500, $requests[0]['data']['amount']); + $I->assertSame('DELETE', $requests[1]['method']); + $I->assertSame('refund.json', $requests[2]['urn']); + $I->assertSame(250, $requests[2]['data']['amount']); + $I->assertSame('recurring.json', $requests[3]['urn']); + $I->assertSame('simulation.json', $requests[4]['urn']); + } + + #[Group('payment')] + #[DataProvider('apiErrors')] + public function mapsApiErrors(IntegrationTester $I, Example $example): void + { + $client = new Client((new FakeTransport())->willReturnJson($example['response'])); + + $I->expectThrowable($example['exception'], function () use ($I, $client, $example): void { + if ($example['operation'] === 'status') { + $client->getStatus('missing'); + return; + } + if ($example['operation'] === 'capture') { + $client->capturePreauth('PAY-1', Money::ofCents(100)); + return; + } + $client->createPayment($I->createPayment()); + }); + } + + protected function apiErrors(): array + { + return [ + 'general API error' => ['response' => ['code' => 1500, 'message' => 'Fixture API error'], 'operation' => 'status', 'exception' => ApiException::class], + 'payment not found' => ['response' => ['code' => 1400, 'message' => 'Fixture missing parameter'], 'operation' => 'status', 'exception' => PaymentNotFoundException::class], + 'preauth rejected' => ['response' => ['code' => 1401, 'message' => 'Fixture preauth error'], 'operation' => 'capture', 'exception' => PreauthException::class], + 'missing create parameter' => ['response' => ['code' => 1400, 'message' => 'Fixture missing parameter'], 'operation' => 'create', 'exception' => MissingParamException::class], + ]; + } +} diff --git a/tests/Integration/FixtureClientCest.php b/tests/Integration/FixtureClientCest.php deleted file mode 100644 index 3ed08f7..0000000 --- a/tests/Integration/FixtureClientCest.php +++ /dev/null @@ -1,126 +0,0 @@ -api = new IntegrationApi(); - $this->api->start(); - } - - public function _after(): void - { - $this->api->stop(); - } - - #[Group('payment')] - #[Group('fixture')] - public function paymentLifecycleUsesExpectedProtocol(IntegrationTester $I): void - { - $client = $this->api->client(); - $payment = $I->createPayment(); - $created = $client->createPayment($payment); - - $I->assertSame('PAY-123', $created->getTransId()); - $I->assertSame('https://pay.test/PAY-123', $created->getRedirect()); - $I->assertSame('PAY-123', $created->toArray()['transId']); - - $status = $client->getStatus($created->getTransId()); - $I->assertTrue($status->isTest()); - $I->assertSame(12345, $status->getPrice()->get()); - $I->assertSame('CARD_CZ_CSOB_2', $status->getMethod()); - $I->assertSame('************1111', $status->getCardNumber()); - $I->assertSame('fixed', $status->toArray()['appliedFeeTyp']); - - $cancelled = $client->cancelPayment($created->getTransId()); - $I->assertSame(['code' => 0, 'message' => 'OK'], $cancelled->toArray()); - - $requests = $this->api->requests(); - $I->assertSame('POST', $requests[0]['method']); - $I->assertSame('/payment.json', $requests[0]['path']); - $I->assertSame('Basic ' . base64_encode('fixture-merchant:fixture-secret'), $requests[0]['headers']['Authorization']); - $I->assertSame('SDK test payment', json_decode($requests[0]['body'], true)['label']); - $I->assertSame('GET', $requests[1]['method']); - $I->assertSame('/payment/transId/PAY-123.json', $requests[1]['path']); - $I->assertSame('DELETE', $requests[2]['method']); - } - - #[Group('payment')] - #[Group('fixture')] - public function paymentOperationsSerializeAndHydrate(IntegrationTester $I): void - { - $client = $this->api->client(); - $I->assertSame(0, $client->capturePreauth('PAY/A B', Money::ofCents(500))->getCode()); - $I->assertSame(0, $client->cancelPreauth('PAY/A B')->getCode()); - - $refund = (new Refund()) - ->setTransId('PAY-123') - ->setAmount(Money::ofCents(250)) - ->setCurrency(CurrencyCode::CZK) - ->setTest(true) - ->setRefId('REF-1'); - $I->assertSame(0, $client->refundPayment($refund)->getCode()); - - $recurring = $I->createPayment(); - $recurring->setInitRecurringId('PAY-123'); - $I->assertSame('REC-123', $client->initRecurringPayment($recurring)->getTransId()); - $I->assertSame(0, $client->simulation(['subject' => 'payment', 'transId' => 'PAY-123', 'task' => 'PAID'])->getCode()); - - $requests = $this->api->requests(); - $I->assertSame('PUT', $requests[0]['method']); - $I->assertSame('/preauth/transId/PAY%2FA+B.json', $requests[0]['path']); - $I->assertSame(500, json_decode($requests[0]['body'], true)['amount']); - $I->assertSame('DELETE', $requests[1]['method']); - $I->assertSame('/refund.json', $requests[2]['path']); - $I->assertSame(250, json_decode($requests[2]['body'], true)['amount']); - $I->assertSame('/recurring.json', $requests[3]['path']); - $I->assertSame('/simulation.json', $requests[4]['path']); - } - - #[Group('payment')] - #[Group('fixture')] - #[DataProvider('apiErrors')] - public function mapsApiErrors(IntegrationTester $I, Example $example): void - { - $client = $this->api->client($example['mode']); - $I->expectThrowable($example['exception'], function () use ($I, $client, $example): void { - if ($example['operation'] === 'status') { - $client->getStatus('missing'); - return; - } - if ($example['operation'] === 'capture') { - $client->capturePreauth('PAY-1', Money::ofCents(100)); - return; - } - $client->createPayment($I->createPayment()); - }); - } - - protected function apiErrors(): array - { - return [ - 'general API error' => ['mode' => 'api-error', 'operation' => 'status', 'exception' => ApiException::class], - 'payment not found' => ['mode' => 'missing', 'operation' => 'status', 'exception' => PaymentNotFoundException::class], - 'preauth rejected' => ['mode' => 'preauth-error', 'operation' => 'capture', 'exception' => PreauthException::class], - 'missing create parameter' => ['mode' => 'missing', 'operation' => 'create', 'exception' => MissingParamException::class], - ]; - } -} diff --git a/tests/Integration/FixtureMotoPaymentCest.php b/tests/Integration/FixtureMotoPaymentCest.php deleted file mode 100644 index ffb55d6..0000000 --- a/tests/Integration/FixtureMotoPaymentCest.php +++ /dev/null @@ -1,83 +0,0 @@ -api = new IntegrationApi(); - $this->api->start(); - } - - public function _after(): void - { - $this->api->stop(); - } - - #[Group('moto')] - #[Group('fixture')] - public function encryptsCardDataBeforeSending(IntegrationTester $I): void - { - $payment = $I->createPayment(); - $card = new PaymentCard('4111111111111111', '203012', '123'); - $response = $this->api->client()->createMotoPayment($payment, $card); - $I->assertSame('MOTO-1', $response->getTransId()); - $I->assertSame('PAID', $response->toArray()['status']); - - $body = json_decode($this->api->requests()[1]['body'], true); - $I->assertSame('4111111111111111', $this->api->decrypt($body['encryptedCardNumber'])); - $I->assertSame('203012', $this->api->decrypt($body['encryptedCardExpiration'])); - $I->assertSame('123', $this->api->decrypt($body['encryptedCardCvv'])); - $I->assertSame('/pubCryptoKey.json', $this->api->requests()[0]['path']); - $I->assertSame('/moto.json', $this->api->requests()[1]['path']); - } - - #[Group('moto')] - #[Group('fixture')] - public function rejectsMissingPublicKey(IntegrationTester $I): void - { - $I->expectThrowable(new Exception('No public encryption key for encrypting card data'), function () use ($I): void { - $this->api->client('no-key')->createMotoPayment($I->createPayment(), new PaymentCard('4111111111111111', '203012', '123')); - }); - } - - #[Group('moto')] - #[Group('fixture')] - #[DataProvider('missingRequiredCardFields')] - public function rejectsMissingRequiredCardFields(IntegrationTester $I, Example $example): void - { - $I->expectThrowable(new Exception($example['message']), function () use ($I, $example): void { - $card = new PaymentCard($example['number'], $example['expiration'], '123'); - $this->api->client()->createMotoPayment($I->createPayment(), $card); - }); - } - - protected function missingRequiredCardFields(): array - { - return [ - 'card number' => ['number' => null, 'expiration' => '203012', 'message' => 'No card number for encrypting card data'], - 'expiration' => ['number' => '4111111111111111', 'expiration' => null, 'message' => 'No card expiration for encrypting card data'], - ]; - } - - #[Group('moto')] - #[Group('fixture')] - public function permitsMissingCvv(IntegrationTester $I): void - { - $response = $this->api->client()->createMotoPayment($I->createPayment(), new PaymentCard('4111111111111111', '203012')); - $I->assertSame('MOTO-1', $response->getTransId()); - $I->assertArrayNotHasKey('encryptedCardCvv', json_decode($this->api->requests()[1]['body'], true)); - } -} diff --git a/tests/Integration/FixtureTerminalClientCest.php b/tests/Integration/FixtureTerminalClientCest.php deleted file mode 100644 index 45ab4e4..0000000 --- a/tests/Integration/FixtureTerminalClientCest.php +++ /dev/null @@ -1,66 +0,0 @@ -api = new IntegrationApi(); - $this->api->start(); - } - - public function _after(): void - { - $this->api->stop(); - } - - #[Group('terminal')] - #[Group('fixture')] - public function paymentLifecycle(IntegrationTester $I): void - { - $client = $this->api->terminalClient(); - $payment = (new TerminalPayment())->setPrice(12.50)->setCurr('CZK')->setRefId('TP-1'); - $created = $client->createPayment($payment); - $I->assertSame('TERM-PAY-1', $created->toArray()['transId']); - - $status = $client->getPaymentStatus('TERM-PAY-1'); - $I->assertSame('PAID', $status->getStatus()); - $I->assertSame(1250, $status->toArray()['price']); - $I->assertFalse($status->isReversed()); - $I->assertSame(0, $client->cancelPayment('TERM-PAY-1')->getCode()); - - $requests = $this->api->requests(); - $I->assertSame(['price' => 1250, 'curr' => 'CZK', 'refId' => 'TP-1'], json_decode($requests[0]['body'], true)); - $I->assertSame('/terminalPayment/transId/TERM-PAY-1.json', $requests[1]['path']); - $I->assertSame('DELETE', $requests[2]['method']); - } - - #[Group('terminal')] - #[Group('fixture')] - public function refundClosingAndStatusLifecycle(IntegrationTester $I): void - { - $client = $this->api->terminalClient(); - $refund = (new TerminalRefund())->setPrice(5)->setCurr('CZK')->setRefId('TR-1'); - $I->assertSame('TERM-REF-1', $client->createRefund($refund)->getTransId()); - - $status = $client->getRefundStatus('TERM-REF-1'); - $I->assertSame('REFUNDED', $status->getStatus()); - $I->assertTrue($status->isReversed()); - $I->assertSame(0, $client->cancelRefund('TERM-REF-1')->getCode()); - - $closing = $client->createClosing(); - $I->assertSame(12, $closing->getBatchNumber()); - $I->assertSame(2, $closing->toArray()['batchData'][0]['count']); - $I->assertSame('ONLINE', $client->getTerminalStatus()->toArray()['status']); - } -} diff --git a/tests/Integration/FixtureTransferCest.php b/tests/Integration/FixtureTransferCest.php deleted file mode 100644 index c643719..0000000 --- a/tests/Integration/FixtureTransferCest.php +++ /dev/null @@ -1,164 +0,0 @@ -api = new IntegrationApi(); - $this->api->start(); - } - - public function _after(): void - { - $this->api->stop(); - } - - #[Group('transfer')] - #[Group('fixture')] - public function hydratesTransferCollections(IntegrationTester $I): void - { - $client = $this->api->client(); - $transfers = $client->transferList(new DateTimeImmutable('2026-07-21'), true)->getTransferList(); - $I->assertCount(2, $transfers); - $I->assertSame(1, $transfers[0]->getTransferId()); - $I->assertSame('2026-07-20', $transfers[0]->getTransferDate()->format('Y-m-d')); - $I->assertSame('43', $transfers[1]->getVariableSymbol()); - - $payments = $client->singleTransfer(42, true)->getPaymentsList(); - $I->assertCount(2, $payments); - $I->assertSame('PAY-1', $payments[0]->getData()['transId']); - - $requests = $this->api->requests(); - $I->assertSame('/transferList/date/2026-07-21.json', $requests[0]['path']); - $I->assertSame(['test' => 'true'], $requests[0]['query']); - $I->assertSame('/singleTransfer/transferId/42.json', $requests[1]['path']); - } - - #[Group('transfer')] - #[Group('fixture')] - public function savesCsvTransferToTemporaryFile(IntegrationTester $I): void - { - $response = $this->api->client()->csvSingleTransfer('42', true); - $I->assertSame('transfer.csv', $response->getFilename()); - $I->assertStringContainsString('1,100', $response->getFileContent()); - - $directory = sys_get_temp_dir() . '/comgate-csv-' . uniqid('', true); - mkdir($directory); - $response->saveToFile($directory); - $I->assertSame($response->getFileContent(), file_get_contents($directory . '/transfer.csv')); - unlink($directory . '/transfer.csv'); - rmdir($directory); - } - - #[Group('transfer')] - #[Group('fixture')] - #[DataProvider('aboFormats')] - public function handlesAboTransferFormats(IntegrationTester $I, Example $example): void - { - $response = $this->api->client()->aboSingleTransfer('42', true, $example['type'], $example['encoding']); - $I->assertSame('transfer.abo', $response->getFilename()); - $I->assertSame('UHL1 0100 100', $response->getFileContent()); - $request = $this->api->requests()[0]; - $I->assertSame($example['type'], $request['query']['type']); - $I->assertSame($example['encoding'], $request['query']['encoding']); - } - - protected function aboFormats(): array - { - return [ - 'v1 utf8' => ['type' => AboSingleTransferRequest::ABO_TYPE_V1, 'encoding' => AboSingleTransferRequest::ABO_ENCODING_UTF8], - 'v2 windows' => ['type' => AboSingleTransferRequest::ABO_TYPE_V2, 'encoding' => AboSingleTransferRequest::ABO_ENCODING_WINDOWS], - ]; - } - - #[Group('transfer')] - #[Group('fixture')] - public function mapsTransferFileApiErrors(IntegrationTester $I): void - { - $I->expectThrowable(ApiException::class, function (): void { - $this->api->client('api-error')->csvSingleTransfer('42', false); - }); - } - - #[Group('transfer')] - #[Group('fixture')] - public function requestsAppleAssociationWithFilters(IntegrationTester $I): void - { - $response = $this->api->client()->getAppleDomainAssociation('APPLE_PAY', 'CZK'); - $I->assertSame('Comgate\SDK\Entity\Response\AppleDomainAssociationResponse', get_class($response)); - $request = $this->api->requests()[0]; - $I->assertSame('CZK', $request['query']['currency']); - $I->assertArrayNotHasKey('method', $request['query']); - } - - #[Group('transfer')] - #[Group('fixture')] - #[DataProvider('downloads')] - public function streamsDownloadsInSubprocess(IntegrationTester $I, Example $example): void - { - $autoload = dirname(__DIR__, 2) . '/vendor/autoload.php'; - $source = dirname(__DIR__, 2) . '/src'; - $sourceFiles = []; - foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source)) as $file) { - if ($file->isFile() && $file->getExtension() === 'php') { - $sourceFiles[] = $file->getPathname(); - } - } - $coverageFile = tempnam(sys_get_temp_dir(), 'comgate-download-coverage-'); - $I->assertNotFalse($coverageFile); - $code = 'require ' . var_export($autoload, true) . '; ' - . '$filter=new \SebastianBergmann\CodeCoverage\Filter(); ' - . '$filter->includeFiles(' . var_export($sourceFiles, true) . '); ' - . '$driver=(new \SebastianBergmann\CodeCoverage\Driver\Selector())->forLineCoverage($filter); ' - . '$coverage=new \SebastianBergmann\CodeCoverage\CodeCoverage($driver,$filter); ' - . '$coverage->start("' . $example['method'] . '"); ' - . 'register_shutdown_function(function()use($coverage){$coverage->stop();file_put_contents(' - . var_export($coverageFile, true) . ',serialize($coverage));}); ' - . '$client=\Comgate\SDK\Comgate::defaults()->setMerchant("fixture-merchant")->setSecret("fixture-secret")->setUrl(' . var_export($this->api->url(), true) . ')->createClient(); ' - . '$client->' . $example['method'] . '(' . $example['arguments'] . ');'; - $process = proc_open( - [PHP_BINARY, '-d', 'xdebug.mode=coverage', '-r', $code], - [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], - $pipes, - null, - array_merge($_ENV, ['XDEBUG_MODE' => 'coverage']) - ); - $I->assertIsResource($process); - $output = stream_get_contents($pipes[1]); - $error = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - $I->assertSame(0, proc_close($process), $error); - $I->assertStringContainsString($example['content'], $output); - $I->assertSame($example['path'], $this->api->requests()[0]['path']); - $downloadCoverage = unserialize(file_get_contents($coverageFile)); - $I->assertInstanceOf(\SebastianBergmann\CodeCoverage\CodeCoverage::class, $downloadCoverage); - if (getenv('XDEBUG_MODE') === 'coverage') { - PhpCodeCoverageFactory::build()->merge($downloadCoverage); - } - unlink($coverageFile); - } - - protected function downloads(): array - { - return [ - 'CSV' => ['method' => 'getCsvDownload', 'arguments' => '"2026-07-21", true', 'content' => 'daily.csv', 'path' => '/csvDownload/date/2026-07-21'], - 'ABO' => ['method' => 'getAboDownload', 'arguments' => '"2026-07-21", "v2", true, "utf8"', 'content' => 'daily.abo', 'path' => '/aboDownload/date/2026-07-21'], - ]; - } -} diff --git a/tests/Integration/FixtureTransportCest.php b/tests/Integration/FixtureTransportCest.php deleted file mode 100644 index 6cb760f..0000000 --- a/tests/Integration/FixtureTransportCest.php +++ /dev/null @@ -1,134 +0,0 @@ -api = new IntegrationApi(); - $this->api->start(); - } - - public function _after(): void - { - $this->api->stop(); - } - - #[Group('transport')] - #[Group('fixture')] - public function preservesResponseHeadersAndBody(IntegrationTester $I): void - { - $transport = new Transport(new Config('fixture-merchant', 'fixture-secret', $this->api->url())); - $response = $transport->get('terminal.json'); - $I->assertSame('{"status":"ONLINE"}', $response->getContent()); - $I->assertSame('{"status":"ONLINE"}', $response->getContent()); - $I->assertSame(['Comgate SDK'], $response->getHeader()['X-Fixture']); - $I->assertSame(['first', 'second'], $response->getHeader()['X-Repeated']); - } - - #[Group('transport')] - #[Group('fixture')] - #[DataProvider('httpStatuses')] - public function classifiesHttpStatusInLogger(IntegrationTester $I, Example $example): void - { - $logger = new RecordingLogger(); - $response = $this->api->terminalClient($example['mode'], $logger)->getTerminalStatus(); - $I->assertSame('ONLINE', $response->getStatus()); - $records = $logger->records(); - $I->assertSame($example['level'], $records[3]['level']); - $I->assertStringContainsString($example['message'], $records[3]['message']); - } - - protected function httpStatuses(): array - { - return [ - 'HTTP 400' => ['mode' => 'http-400', 'level' => LogLevel::ERROR, 'message' => 'Client error'], - 'HTTP 500' => ['mode' => 'http-500', 'level' => LogLevel::CRITICAL, 'message' => 'Server error'], - ]; - } - - #[Group('transport')] - #[Group('fixture')] - public function reportsConnectionFailureAndLogsIt(IntegrationTester $I): void - { - $socket = stream_socket_server('tcp://127.0.0.1:0'); - $I->assertIsResource($socket); - $address = stream_socket_get_name($socket, false); - fclose($socket); - $logger = new RecordingLogger(); - $transport = new Transport(new Config('fixture-merchant', 'fixture-secret', 'http://' . $address), $logger); - - $I->expectThrowable(ComgateException::class, function () use ($transport): void { - $transport->get('unavailable.json'); - }); - $records = $logger->records(); - $I->assertSame(LogLevel::ERROR, $records[3]['level']); - $I->assertStringContainsString('cURL request failed', $records[3]['message']); - } - - #[Group('transport')] - #[Group('fixture')] - public function exposesMalformedJsonAsFailure(IntegrationTester $I): void - { - $I->expectThrowable(Throwable::class, function (): void { - $this->api->client('malformed')->getStatus('PAY-123'); - }); - } - - #[Group('helpers')] - #[Group('fixture')] - public function redirectHelperReturnsHttpRedirect(IntegrationTester $I): void - { - $curl = curl_init($this->api->url('helper-redirect') . '?target=' . urlencode('https://example.test/paid')); - curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); - curl_setopt($curl, CURLOPT_HEADER, true); - curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false); - $response = curl_exec($curl); - $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); - curl_close($curl); - - $I->assertIsString($response); - $I->assertSame(302, $status); - $I->assertStringContainsString("Location: https://example.test/paid\r\n", $response); - $I->assertStringEndsWith('redirected', $response); - $this->api->mergeServerCoverage(); - } - - #[Group('transport')] - #[Group('fixture')] - public function parsesEncodedHttpQuery(IntegrationTester $I): void - { - $query = 'message=payment%20accepted%3Dtrue&empty¤cy=CZK&reference=order%26item'; - $curl = curl_init($this->api->url('query-parse') . '?' . $query); - curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); - $response = curl_exec($curl); - $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); - curl_close($curl); - - $I->assertSame(200, $status); - $I->assertIsString($response); - $I->assertSame([ - 'message' => 'payment accepted=true', - 'empty' => '', - 'currency' => 'CZK', - 'reference' => 'order&item', - ], json_decode($response, true)); - $this->api->mergeServerCoverage(); - } -} diff --git a/tests/Integration/MotoPaymentFunctionalCest.php b/tests/Integration/MotoPaymentFunctionalCest.php new file mode 100644 index 0000000..ada83ad --- /dev/null +++ b/tests/Integration/MotoPaymentFunctionalCest.php @@ -0,0 +1,113 @@ +willReturnJson($this->publicKeyResponse()) + ->willReturnJson(['code' => 0, 'message' => 'OK', 'transId' => 'MOTO-1', 'status' => 'PAID']); + + $card = new PaymentCard('4111111111111111', '203012', '123'); + $response = (new Client($transport))->createMotoPayment($I->createPayment(), $card); + $I->assertSame('MOTO-1', $response->getTransId()); + $I->assertSame('PAID', $response->toArray()['status']); + + $requests = $transport->requests(); + $body = $requests[1]['data']; + $I->assertSame('4111111111111111', $this->decrypt($body['encryptedCardNumber'])); + $I->assertSame('203012', $this->decrypt($body['encryptedCardExpiration'])); + $I->assertSame('123', $this->decrypt($body['encryptedCardCvv'])); + $I->assertSame('pubCryptoKey.json', $requests[0]['urn']); + $I->assertSame('moto.json', $requests[1]['urn']); + } + + #[Group('moto')] + public function rejectsMissingPublicKey(IntegrationTester $I): void + { + $transport = (new FakeTransport())->willReturnJson([ + 'code' => 0, 'message' => 'OK', 'key' => base64_encode((string) json_encode(['notJwk' => []])), + ]); + + $I->expectThrowable(new Exception('No public encryption key for encrypting card data'), function () use ($I, $transport): void { + (new Client($transport))->createMotoPayment($I->createPayment(), new PaymentCard('4111111111111111', '203012', '123')); + }); + } + + #[Group('moto')] + #[DataProvider('missingRequiredCardFields')] + public function rejectsMissingRequiredCardFields(IntegrationTester $I, Example $example): void + { + $transport = (new FakeTransport())->willReturnJson($this->publicKeyResponse()); + + $I->expectThrowable(new Exception($example['message']), function () use ($I, $example, $transport): void { + $card = new PaymentCard($example['number'], $example['expiration'], '123'); + (new Client($transport))->createMotoPayment($I->createPayment(), $card); + }); + } + + protected function missingRequiredCardFields(): array + { + return [ + 'card number' => ['number' => null, 'expiration' => '203012', 'message' => 'No card number for encrypting card data'], + 'expiration' => ['number' => '4111111111111111', 'expiration' => null, 'message' => 'No card expiration for encrypting card data'], + ]; + } + + #[Group('moto')] + public function permitsMissingCvv(IntegrationTester $I): void + { + $transport = (new FakeTransport()) + ->willReturnJson($this->publicKeyResponse()) + ->willReturnJson(['code' => 0, 'message' => 'OK', 'transId' => 'MOTO-1', 'status' => 'PAID']); + + $response = (new Client($transport))->createMotoPayment($I->createPayment(), new PaymentCard('4111111111111111', '203012')); + $I->assertSame('MOTO-1', $response->getTransId()); + $I->assertArrayNotHasKey('encryptedCardCvv', $transport->requests()[1]['data']); + } + + /** + * Builds a fresh RSA key pair, keeps the private half for decryption and + * returns the pubCryptoKey.json body carrying the public key as a JWK. + * + * @return array + */ + private function publicKeyResponse(): array + { + $this->privateKey = RSA::createKey(1024); + $publicJwk = $this->privateKey->getPublicKey()->toString('JWK'); + + return [ + 'code' => 0, + 'message' => 'OK', + 'key' => base64_encode((string) json_encode(['jwk' => json_decode($publicJwk, true)])), + ]; + } + + private function decrypt(string $ciphertext): string + { + return $this->privateKey->decrypt(base64_decode($ciphertext, true)); + } +} diff --git a/tests/Integration/TerminalClientFunctionalCest.php b/tests/Integration/TerminalClientFunctionalCest.php new file mode 100644 index 0000000..8e4135d --- /dev/null +++ b/tests/Integration/TerminalClientFunctionalCest.php @@ -0,0 +1,72 @@ +willReturnJson(['code' => 0, 'message' => 'OK', 'transId' => 'TERM-PAY-1']) + ->willReturnJson([ + 'code' => 0, 'message' => 'OK', 'price' => 1250, 'curr' => 'CZK', 'refId' => 'TP-1', 'transId' => 'TERM-PAY-1', + 'status' => 'PAID', 'fee' => '10', 'cardValid' => '12/30', 'cardNumber' => '****1111', + 'paymentErrorReason' => '', 'reversed' => false, 'amountRefunded' => '0', + ]) + ->willReturnJson(['code' => 0, 'message' => 'OK']); + $client = new ClientTerminal($transport); + + $created = $client->createPayment((new TerminalPayment())->setPrice(12.50)->setCurr('CZK')->setRefId('TP-1')); + $I->assertSame('TERM-PAY-1', $created->toArray()['transId']); + + $status = $client->getPaymentStatus('TERM-PAY-1'); + $I->assertSame('PAID', $status->getStatus()); + $I->assertSame(1250, $status->toArray()['price']); + $I->assertFalse($status->isReversed()); + $I->assertSame(0, $client->cancelPayment('TERM-PAY-1')->getCode()); + + $requests = $transport->requests(); + $I->assertSame(['price' => 1250, 'curr' => 'CZK', 'refId' => 'TP-1'], $requests[0]['data']); + $I->assertSame('terminalPayment/transId/TERM-PAY-1.json', $requests[1]['urn']); + $I->assertSame('DELETE', $requests[2]['method']); + } + + #[Group('terminal')] + public function refundClosingAndStatusLifecycle(IntegrationTester $I): void + { + $transport = (new FakeTransport()) + ->willReturnJson(['code' => 0, 'message' => 'OK', 'transId' => 'TERM-REF-1']) + ->willReturnJson([ + 'code' => 0, 'message' => 'OK', 'price' => 500, 'curr' => 'CZK', 'refId' => 'TR-1', 'transId' => 'TERM-REF-1', + 'status' => 'REFUNDED', 'cardNumber' => '****1111', 'reversed' => true, + ]) + ->willReturnJson(['code' => 0, 'message' => 'OK']) + ->willReturnJson(['code' => 0, 'message' => 'OK', 'batchNumber' => 12, 'batchData' => [['count' => 2, 'amount' => 1750]]]) + ->willReturnJson(['status' => 'ONLINE']); + $client = new ClientTerminal($transport); + + $I->assertSame('TERM-REF-1', $client->createRefund((new TerminalRefund())->setPrice(5)->setCurr('CZK')->setRefId('TR-1'))->getTransId()); + + $status = $client->getRefundStatus('TERM-REF-1'); + $I->assertSame('REFUNDED', $status->getStatus()); + $I->assertTrue($status->isReversed()); + $I->assertSame(0, $client->cancelRefund('TERM-REF-1')->getCode()); + + $closing = $client->createClosing(); + $I->assertSame(12, $closing->getBatchNumber()); + $I->assertSame(2, $closing->toArray()['batchData'][0]['count']); + $I->assertSame('ONLINE', $client->getTerminalStatus()->toArray()['status']); + } +} diff --git a/tests/Integration/TransferFunctionalCest.php b/tests/Integration/TransferFunctionalCest.php new file mode 100644 index 0000000..4fc9295 --- /dev/null +++ b/tests/Integration/TransferFunctionalCest.php @@ -0,0 +1,104 @@ +willReturnJson([ + ['transferId' => 1, 'transferDate' => '2026-07-20', 'accountCounterparty' => '0/0000', 'accountOutgoing' => '1/0000', 'variableSymbol' => '42'], + ['transferId' => 2, 'transferDate' => '2026-07-21', 'accountCounterparty' => '2/0100', 'accountOutgoing' => '3/0300', 'variableSymbol' => '43'], + ]) + ->willReturnJson([['transId' => 'PAY-1', 'price' => 100], ['transId' => 'PAY-2', 'price' => 200]]); + $client = new Client($transport); + + $transfers = $client->transferList(new DateTimeImmutable('2026-07-21'), true)->getTransferList(); + $I->assertCount(2, $transfers); + $I->assertSame(1, $transfers[0]->getTransferId()); + $I->assertSame('2026-07-20', $transfers[0]->getTransferDate()->format('Y-m-d')); + $I->assertSame('43', $transfers[1]->getVariableSymbol()); + + $payments = $client->singleTransfer(42, true)->getPaymentsList(); + $I->assertCount(2, $payments); + $I->assertSame('PAY-1', $payments[0]->getData()['transId']); + + $requests = $transport->requests(); + $I->assertSame('transferList/date/2026-07-21.json?test=true', $requests[0]['urn']); + $I->assertSame('singleTransfer/transferId/42.json?test=true', $requests[1]['urn']); + } + + #[Group('transfer')] + public function savesCsvTransferToTemporaryFile(IntegrationTester $I): void + { + $transport = (new FakeTransport())->willReturnJson(['nazev' => 'transfer.csv', 'csv' => base64_encode("id,amount\n1,100\n")]); + $response = (new Client($transport))->csvSingleTransfer('42', true); + $I->assertSame('transfer.csv', $response->getFilename()); + $I->assertStringContainsString('1,100', $response->getFileContent()); + + $directory = sys_get_temp_dir() . '/comgate-csv-' . uniqid('', true); + mkdir($directory); + $response->saveToFile($directory); + $I->assertSame($response->getFileContent(), file_get_contents($directory . '/transfer.csv')); + unlink($directory . '/transfer.csv'); + rmdir($directory); + } + + #[Group('transfer')] + #[DataProvider('aboFormats')] + public function handlesAboTransferFormats(IntegrationTester $I, Example $example): void + { + $transport = (new FakeTransport())->willReturnJson(['nazev' => 'transfer.abo', 'abo' => base64_encode('UHL1 0100 100')]); + $response = (new Client($transport))->aboSingleTransfer('42', true, $example['type'], $example['encoding']); + $I->assertSame('transfer.abo', $response->getFilename()); + $I->assertSame('UHL1 0100 100', $response->getFileContent()); + + $urn = $transport->requests()[0]['urn']; + $I->assertStringContainsString('type=' . $example['type'], $urn); + $I->assertStringContainsString('encoding=' . $example['encoding'], $urn); + } + + protected function aboFormats(): array + { + return [ + 'v1 utf8' => ['type' => AboSingleTransferRequest::ABO_TYPE_V1, 'encoding' => AboSingleTransferRequest::ABO_ENCODING_UTF8], + 'v2 windows' => ['type' => AboSingleTransferRequest::ABO_TYPE_V2, 'encoding' => AboSingleTransferRequest::ABO_ENCODING_WINDOWS], + ]; + } + + #[Group('transfer')] + public function mapsTransferFileApiErrors(IntegrationTester $I): void + { + $transport = (new FakeTransport())->willReturnJson(['code' => 1500, 'message' => 'Fixture API error']); + $I->expectThrowable(ApiException::class, function () use ($transport): void { + (new Client($transport))->csvSingleTransfer('42', false); + }); + } + + #[Group('transfer')] + public function requestsAppleAssociationWithFilters(IntegrationTester $I): void + { + $transport = (new FakeTransport())->willReturnJson(['fileContent' => 'apple-domain-association']); + $response = (new Client($transport))->getAppleDomainAssociation('APPLE_PAY', 'CZK'); + $I->assertSame('Comgate\SDK\Entity\Response\AppleDomainAssociationResponse', get_class($response)); + + $urn = $transport->requests()[0]['urn']; + $I->assertStringContainsString('currency=CZK', $urn); + $I->assertStringNotContainsString('method', $urn); + } +} diff --git a/tests/Support/FakeTransport.php b/tests/Support/FakeTransport.php new file mode 100644 index 0000000..f49fdb1 --- /dev/null +++ b/tests/Support/FakeTransport.php @@ -0,0 +1,118 @@ + */ + private $responses = []; + + /** @var array}> */ + private $requests = []; + + /** + * Queue the next response as a JSON body built from $data - the shape the + * Comgate API returns for a successful call. + * + * @param array $data + */ + public function willReturnJson(array $data): self + { + return $this->willReturn((string) json_encode($data)); + } + + /** + * Queue the next response with a raw (possibly malformed) body. + */ + public function willReturn(string $body): self + { + $message = (new PsrResponse()) + ->withHeader('Content-Type', 'application/json') + ->withBody(new PsrStream($body)); + $this->responses[] = new Response($message); + + return $this; + } + + /** + * @param mixed[] $data + * @param mixed[] $options + */ + public function post(string $uri, array $data, array $options = []): Response + { + return $this->record('POST', $uri, $data); + } + + /** + * @param mixed[] $data + * @param mixed[] $options + */ + public function postJson(string $uri, array $data, array $options = []): Response + { + return $this->record('POST', $uri, $data); + } + + /** + * @param mixed[] $options + */ + public function get(string $uri, array $options = []): Response + { + return $this->record('GET', $uri, []); + } + + /** + * @param mixed[] $options + */ + public function delete(string $uri, array $options = []): Response + { + return $this->record('DELETE', $uri, []); + } + + /** + * @param mixed[] $data + * @param mixed[] $options + */ + public function putJson(string $uri, array $data, array $options = []): Response + { + return $this->record('PUT', $uri, $data); + } + + /** + * Everything the SDK sent, in call order. Each entry is the HTTP method, the + * URN (path + query string) and the payload the SDK handed to the transport. + * + * @return array}> + */ + public function requests(): array + { + return $this->requests; + } + + /** + * @param array $data + */ + private function record(string $method, string $urn, array $data): Response + { + $this->requests[] = ['method' => $method, 'urn' => $urn, 'data' => $data]; + + if ($this->responses === []) { + throw new RuntimeException(sprintf('FakeTransport has no queued response for %s %s', $method, $urn)); + } + + return array_shift($this->responses); + } +} diff --git a/tests/Support/Fixture/IntegrationApi.php b/tests/Support/Fixture/IntegrationApi.php deleted file mode 100644 index b022d8e..0000000 --- a/tests/Support/Fixture/IntegrationApi.php +++ /dev/null @@ -1,171 +0,0 @@ - */ - private $pipes = []; - - /** @var string */ - private $url; - - /** @var string */ - private $requestLog; - - /** @var string */ - private $coverageFile; - - /** @var PrivateKey */ - private $privateKey; - - public function start(): void - { - $socket = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); - if ($socket === false) { - throw new RuntimeException($errorMessage, $errorCode); - } - - $address = stream_socket_get_name($socket, false); - fclose($socket); - $port = (int) substr((string) $address, strrpos((string) $address, ':') + 1); - - $this->requestLog = tempnam(sys_get_temp_dir(), 'comgate-sdk-requests-'); - if ($this->requestLog === false) { - throw new RuntimeException('Unable to create fixture request log'); - } - $this->coverageFile = tempnam(sys_get_temp_dir(), 'comgate-sdk-server-coverage-'); - if ($this->coverageFile === false) { - throw new RuntimeException('Unable to create fixture coverage file'); - } - - $router = __DIR__ . '/router.php'; - $command = [PHP_BINARY, '-d', 'xdebug.mode=coverage', '-S', '127.0.0.1:' . $port, $router]; - $this->privateKey = RSA::createKey(1024); - $publicJwk = $this->privateKey->getPublicKey()->toString('JWK'); - $environment = array_merge($_ENV, [ - 'COMGATE_FIXTURE_REQUEST_LOG' => $this->requestLog, - 'COMGATE_FIXTURE_PUBLIC_JWK' => base64_encode($publicJwk), - 'COMGATE_FIXTURE_COVERAGE_FILE' => $this->coverageFile, - 'XDEBUG_MODE' => 'coverage', - ]); - $this->process = proc_open($command, [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], $this->pipes, dirname($router), $environment); - - if (!is_resource($this->process)) { - throw new RuntimeException('Unable to start fixture API'); - } - - $this->url = 'http://127.0.0.1:' . $port . '/'; - for ($attempt = 0; $attempt < 50; $attempt++) { - $connection = @fsockopen('127.0.0.1', $port); - if (is_resource($connection)) { - fclose($connection); - return; - } - usleep(20000); - } - - $this->stop(); - throw new RuntimeException('Fixture API did not start'); - } - - public function stop(): void - { - foreach ($this->pipes as $pipe) { - if (is_resource($pipe)) { - fclose($pipe); - } - } - $this->pipes = []; - - if (is_resource($this->process)) { - proc_terminate($this->process); - proc_close($this->process); - } - $this->process = null; - - if (isset($this->requestLog) && is_file($this->requestLog)) { - unlink($this->requestLog); - } - if (isset($this->coverageFile) && is_file($this->coverageFile)) { - unlink($this->coverageFile); - } - } - - public function client(string $mode = 'success', ?LoggerInterface $logger = null): Client - { - return $this->comgate($mode, $logger)->createClient(); - } - - public function terminalClient(string $mode = 'success', ?LoggerInterface $logger = null): ClientTerminal - { - return $this->comgate($mode, $logger)->createTerminalClient(); - } - - public function url(string $mode = 'success'): string - { - return $this->url . $mode; - } - - /** @return array> */ - public function requests(): array - { - $requests = []; - foreach (file($this->requestLog, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { - $request = json_decode($line, true); - if (is_array($request)) { - $requests[] = $request; - } - } - return $requests; - } - - public function reset(): void - { - file_put_contents($this->requestLog, ''); - } - - public function mergeServerCoverage(): void - { - $coverage = unserialize(file_get_contents($this->coverageFile)); - if (!$coverage instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { - throw new RuntimeException('Fixture server did not produce code coverage'); - } - if (getenv('XDEBUG_MODE') === 'coverage') { - PhpCodeCoverageFactory::build()->merge($coverage); - } - } - - public function decrypt(string $ciphertext): string - { - return $this->privateKey->decrypt(base64_decode($ciphertext, true)); - } - - private function comgate(string $mode, ?LoggerInterface $logger): Comgate - { - $comgate = Comgate::defaults() - ->setMerchant('fixture-merchant') - ->setSecret('fixture-secret') - ->setUrl($this->url . $mode); - if ($logger !== null) { - $comgate->setLogger($logger); - } - return $comgate; - } -} diff --git a/tests/Support/Fixture/RecordingLogger.php b/tests/Support/Fixture/RecordingLogger.php deleted file mode 100644 index 4ea5120..0000000 --- a/tests/Support/Fixture/RecordingLogger.php +++ /dev/null @@ -1,23 +0,0 @@ - */ - private $records = []; - - /** @param mixed $level @param mixed $message @param array $context */ - public function log($level, $message, array $context = []): void - { - $this->records[] = ['level' => $level, 'message' => (string) $message]; - } - - /** @return array */ - public function records(): array - { - return $this->records; - } -} diff --git a/tests/Support/Fixture/router.php b/tests/Support/Fixture/router.php deleted file mode 100644 index bab2ca3..0000000 --- a/tests/Support/Fixture/router.php +++ /dev/null @@ -1,270 +0,0 @@ -/ so every request - * arrives as // , e.g. /success/payment.json or /missing/payment.json . - * - first path segment - selects a behaviour (see MODES below). - * Defaults to "success" for normal happy-path requests. - * - the rest of the path - the Comgate endpoint being called, - * e.g. "payment.json" or "payment/transId/PAY-123.json". - * - * MODES - * success normal response for the requested resource (the default) - * helper-redirect / query-parse run an SDK unit in-process to capture its coverage - * http-400 / http-500 respond with that HTTP status - * body-only strip all response headers - * malformed return invalid JSON - * api-error / missing / preauth-error return a Comgate error envelope - * no-key pubCryptoKey.json returns a non-JWK key - */ - -require dirname(__DIR__, 3) . '/vendor/autoload.php'; - -use Comgate\SDK\Http\Query; -use Comgate\SDK\Utils\Helpers; -use SebastianBergmann\CodeCoverage\CodeCoverage; -use SebastianBergmann\CodeCoverage\Driver\Selector; -use SebastianBergmann\CodeCoverage\Filter; - -/** Send a JSON body and stop. */ -function send(array $data): void -{ - echo json_encode($data); -} - -/** Send a Comgate error envelope and stop. */ -function send_error(int $code, string $message): void -{ - send(['code' => $code, 'message' => $message]); -} - -/** - * Run a single SDK source file under code coverage while $callback executes, - * then serialise the result to the file IntegrationApi will merge back in. - * Used by the helper-redirect / query-parse modes below. - */ -function capture_coverage(string $sourceFile, callable $callback): void -{ - $filter = new Filter(); - $filter->includeFile($sourceFile); - $coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); - $coverage->start('fixture'); - register_shutdown_function(static function () use ($coverage): void { - $coverage->stop(); - file_put_contents((string) getenv('COMGATE_FIXTURE_COVERAGE_FILE'), serialize($coverage)); - }); - $callback(); -} - -// --- Parse the incoming request ------------------------------------------------- - -$path = (string) parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); -$segments = explode('/', trim($path, '/')); -$mode = array_shift($segments) ?: 'success'; -$resource = implode('/', $segments); -$method = $_SERVER['REQUEST_METHOD']; -$body = file_get_contents('php://input'); -$headers = function_exists('getallheaders') ? getallheaders() : []; -$src = dirname(__DIR__, 3) . '/src'; - -// --- Coverage-only modes -------------------------------------------------------- -// These invoke an SDK unit directly so its coverage can be captured; they are not -// real Comgate endpoints, so they return early before the request is logged. - -if ($mode === 'helper-redirect') { - capture_coverage($src . '/Utils/Helpers.php', static function (): void { - Helpers::redirect($_GET['target'] ?? '/'); - }); - echo 'redirected'; - return; -} - -if ($mode === 'query-parse') { - capture_coverage($src . '/Http/Query.php', static function (): void { - header('Content-Type: application/json'); - echo json_encode(Query::parse($_SERVER['QUERY_STRING'] ?? '')); - }); - return; -} - -// --- Record the request so tests can assert what the SDK sent ------------------- - -file_put_contents((string) getenv('COMGATE_FIXTURE_REQUEST_LOG'), json_encode([ - 'mode' => $mode, - 'method' => $method, - 'path' => '/' . $resource, - 'query' => $_GET, - 'headers' => $headers, - 'body' => $body, -], JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX); - -// Default headers every fixture response carries (X-Repeated appears twice on -// purpose, to exercise repeated-header handling in the transport). -header('X-Fixture: Comgate SDK'); -header('X-Repeated: first', false); -header('X-Repeated: second', false); - -// --- Transport-level behaviour modes -------------------------------------------- - -if ($mode === 'http-500') { - http_response_code(500); -} -if ($mode === 'http-400') { - http_response_code(400); -} -if ($mode === 'body-only') { - header_remove(); -} -if ($mode === 'malformed') { - echo '{broken-json'; - return; -} -if ($mode === 'api-error') { - send_error(1500, 'Fixture API error'); - return; -} -if ($mode === 'missing') { - send_error(1400, 'Fixture missing parameter'); - return; -} -if ($mode === 'preauth-error') { - send_error(1401, 'Fixture preauth error'); - return; -} - -// --- Payments ------------------------------------------------------------------- - -if ($resource === 'payment.json') { - send(['code' => 0, 'message' => 'OK', 'transId' => 'PAY-123', 'redirect' => 'https://pay.test/PAY-123']); - return; -} -if (strpos($resource, 'payment/transId/') === 0 && $method === 'GET') { - send([ - 'code' => 0, 'message' => 'OK', 'merchant' => 'fixture-merchant', 'secret' => 'fixture-secret', - 'transId' => 'PAY-123', 'test' => 'true', 'price' => 12345, 'curr' => 'CZK', 'label' => 'Fixture order', - 'refId' => 'ORDER-42', 'payerId' => 'PAYER-7', 'method' => 'CARD_CZ_CSOB_2', 'account' => '123/0100', - 'email' => 'buyer@example.test', 'name' => 'Ada Buyer', 'phone' => '+420123456789', 'status' => 'PAID', - 'payerName' => 'Ada Buyer', 'payerAcc' => '987/0300', 'fee' => '12', 'vs' => '42', 'cardValid' => '12/30', - 'cardNumber' => '************1111', 'appliedFee' => '3', 'appliedFeeTyp' => 'fixed', 'paymentErrorReason' => '', - ]); - return; -} -if ( - strpos($resource, 'payment/transId/') === 0 // cancel payment (DELETE) - || strpos($resource, 'preauth/transId/') === 0 // capture / cancel preauth - || $resource === 'refund.json' - || $resource === 'simulation.json' -) { - send(['code' => 0, 'message' => 'OK']); - return; -} -if ($resource === 'recurring.json') { - send(['code' => 0, 'message' => 'OK', 'transId' => 'REC-123']); - return; -} - -// --- Transfers & downloads ------------------------------------------------------ - -if (strpos($resource, 'transferList/date/') === 0) { - send([ - ['transferId' => 1, 'transferDate' => '2026-07-20', 'accountCounterparty' => '0/0000', 'accountOutgoing' => '1/0000', 'variableSymbol' => '42'], - ['transferId' => 2, 'transferDate' => '2026-07-21', 'accountCounterparty' => '2/0100', 'accountOutgoing' => '3/0300', 'variableSymbol' => '43'], - ]); - return; -} -if (strpos($resource, 'singleTransfer/transferId/') === 0) { - send([['transId' => 'PAY-1', 'price' => 100], ['transId' => 'PAY-2', 'price' => 200]]); - return; -} -if (strpos($resource, 'csvSingleTransfer/transferId/') === 0) { - send(['nazev' => 'transfer.csv', 'csv' => base64_encode("id,amount\n1,100\n")]); - return; -} -if (strpos($resource, 'aboSingleTransfer/transferId/') === 0) { - send(['nazev' => 'transfer.abo', 'abo' => base64_encode('UHL1 0100 100')]); - return; -} -if (strpos($resource, 'csvDownload/date/') === 0) { - send(['nazev' => 'daily.csv', 'csv' => base64_encode("date,amount\n2026-07-21,100\n")]); - return; -} -if (strpos($resource, 'aboDownload/date/') === 0) { - send(['nazev' => 'daily.abo', 'abo' => base64_encode('UHL1 DAILY')]); - return; -} -if (strpos($resource, 'appleDomainAssociation.json') === 0) { - send(['fileContent' => 'apple-domain-association']); - return; -} - -// --- Terminal / CloudPOS -------------------------------------------------------- - -if ($resource === 'terminalPayment.json') { - send(['code' => 0, 'message' => 'OK', 'transId' => 'TERM-PAY-1']); - return; -} -if (strpos($resource, 'terminalPayment/transId/') === 0 && $method === 'GET') { - send(['code' => 0, 'message' => 'OK', 'price' => 1250, 'curr' => 'CZK', 'refId' => 'TP-1', 'transId' => 'TERM-PAY-1', 'status' => 'PAID', 'fee' => '10', 'cardValid' => '12/30', 'cardNumber' => '****1111', 'paymentErrorReason' => '', 'reversed' => false, 'amountRefunded' => '0']); - return; -} -if (strpos($resource, 'terminalPayment/transId/') === 0) { - send(['code' => 0, 'message' => 'OK']); - return; -} -if ($resource === 'terminalRefund.json') { - send(['code' => 0, 'message' => 'OK', 'transId' => 'TERM-REF-1']); - return; -} -if (strpos($resource, 'terminalRefund/transId/') === 0 && $method === 'GET') { - send(['code' => 0, 'message' => 'OK', 'price' => 500, 'curr' => 'CZK', 'refId' => 'TR-1', 'transId' => 'TERM-REF-1', 'status' => 'REFUNDED', 'cardNumber' => '****1111', 'reversed' => true]); - return; -} -if (strpos($resource, 'terminalRefund/transId/') === 0) { - send(['code' => 0, 'message' => 'OK']); - return; -} -if ($resource === 'terminalClosing.json') { - send(['code' => 0, 'message' => 'OK', 'batchNumber' => 12, 'batchData' => [['count' => 2, 'amount' => 1750]]]); - return; -} -if ($resource === 'terminal.json') { - send(['status' => 'ONLINE']); - return; -} - -// --- Crypto key & MOTO ---------------------------------------------------------- - -if ($resource === 'pubCryptoKey.json') { - if ($mode === 'no-key') { - send(['code' => 0, 'message' => 'OK', 'key' => base64_encode(json_encode(['notJwk' => []]))]); - return; - } - $publicKey = base64_decode((string) getenv('COMGATE_FIXTURE_PUBLIC_JWK'), true); - send(['code' => 0, 'message' => 'OK', 'key' => base64_encode(json_encode(['jwk' => json_decode($publicKey, true)]))]); - return; -} -if ($resource === 'moto.json') { - send(['code' => 0, 'message' => 'OK', 'transId' => 'MOTO-1', 'status' => 'PAID']); - return; -} - -// --- Unknown resource ----------------------------------------------------------- - -send_error(1500, 'Unknown fixture route: ' . $resource);