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/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); + } +}