Skip to content

Commit f57ef04

Browse files
authored
Merge pull request #77 from two-inc/doug/ABN-287-error-handling
Doug/abn 287 error handling
2 parents 87bacc1 + 8ac0cec commit f57ef04

20 files changed

Lines changed: 1328 additions & 2 deletions

.github/workflows/phpunit.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: PHPUnit
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
php-version: ['7.4', '8.1', '8.2']
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up PHP ${{ matrix.php-version }}
20+
uses: shivammathur/setup-php@v2
21+
with:
22+
php-version: ${{ matrix.php-version }}
23+
24+
- name: Download PHPUnit
25+
env:
26+
PHPUNIT_VERSION: '9.6.34'
27+
PHPUNIT_SHA256: 'e7264ae61fe58a487c2bd741905b85940d8fbc2b32cf4a279949b6d9a172a06a'
28+
run: |
29+
php -r "copy('https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar', 'phpunit.phar');"
30+
echo "${PHPUNIT_SHA256} phpunit.phar" | sha256sum -c -
31+
32+
- name: Run tests
33+
run: php phpunit.phar

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ bin/magento cache:flush
9898
# Create pub/opcache-clear.php or restart PHP-FPM
9999
```
100100

101+
## Session Artifacts
102+
103+
This is a **public repository**. Do not commit session-specific content such as:
104+
- Session summaries or transcripts
105+
- Implementation plans or review notes
106+
- Any file under `docs/` that contains conversation context
107+
108+
Use agent memory (e.g. `~/.claude/projects/` or equivalent) for session persistence instead. Plans can be saved locally and stashed but must not be committed.
109+
101110
### Common Issues
102111

103112
1. **Template not found error**: Run `setup:di:compile` and clear opcache

Makefile

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ TWO_API_BASE_URL ?= https://api.staging.two.inc
1414
TWO_CHECKOUT_BASE_URL ?= https://checkout.staging.two.inc
1515
TWO_STORE_COUNTRY ?= NO
1616

17-
.PHONY: help install configure compile run stop clean logs archive patch minor major format
17+
.PHONY: help install configure compile run stop clean logs archive patch minor major format test test-e2e
1818

1919
.DEFAULT_GOAL := help
2020

@@ -94,6 +94,26 @@ patch: bumpver-patch
9494
minor: bumpver-minor
9595
## Bump major version
9696
major: bumpver-major
97+
PHPUNIT_VERSION := 9.6.34
98+
PHPUNIT_SHA256 := e7264ae61fe58a487c2bd741905b85940d8fbc2b32cf4a279949b6d9a172a06a
99+
100+
## Run PHPUnit tests
101+
test:
102+
docker run --rm -v $(CURDIR):/app -w /app php:8.1-cli bash -c \
103+
"php -r \"copy('https://phar.phpunit.de/phpunit-$(PHPUNIT_VERSION).phar', '/tmp/phpunit.phar');\" \
104+
&& echo '$(PHPUNIT_SHA256) /tmp/phpunit.phar' | sha256sum -c - \
105+
&& php /tmp/phpunit.phar"
106+
107+
## Run end-to-end API tests (requires TWO_API_KEY)
108+
test-e2e:
109+
docker run --rm -v $(CURDIR):/app -w /app \
110+
-e TWO_API_KEY=$(TWO_API_KEY) \
111+
-e TWO_API_BASE_URL=$(TWO_API_BASE_URL) \
112+
php:8.1-cli bash -c \
113+
"php -r \"copy('https://phar.phpunit.de/phpunit-$(PHPUNIT_VERSION).phar', '/tmp/phpunit.phar');\" \
114+
&& echo '$(PHPUNIT_SHA256) /tmp/phpunit.phar' | sha256sum -c - \
115+
&& php /tmp/phpunit.phar --testsuite E2E"
116+
97117
## Format frontend assets with Prettier
98118
format:
99119
prettier -w view/frontend/web/js/

Model/Two.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ public function getErrorFromResponse(array $response): ?Phrase
325325
$reason = __('The buyer and the seller are the same company.');
326326
}
327327
if ($isClientError && in_array($errorCode, ['SCHEMA_ERROR', 'SAME_BUYER_SELLER_ERROR', 'ORDER_INVALID'])) {
328-
return $reason;
328+
return $reason instanceof Phrase ? $reason : __($reason);
329329
}
330330

331331
// System errors — include trace ID

Test/E2E/ApiAdapterTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Two\Gateway\Test\E2E\Service\Api;
5+
6+
use PHPUnit\Framework\TestCase;
7+
use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository;
8+
use Two\Gateway\Api\Log\RepositoryInterface as LogRepository;
9+
use Two\Gateway\Service\Api\Adapter;
10+
use Two\Gateway\Test\E2E\Http\RealCurl;
11+
12+
/**
13+
* End-to-end tests for Service\Api\Adapter against the real Two API.
14+
* Defaults to staging unless TWO_API_BASE_URL overrides it.
15+
*
16+
* Run via: TWO_API_KEY=xxx make test-e2e
17+
*/
18+
class ApiAdapterTest extends TestCase
19+
{
20+
private Adapter $adapter;
21+
22+
protected function setUp(): void
23+
{
24+
$apiKey = (string)getenv('TWO_API_KEY');
25+
$baseUrl = getenv('TWO_API_BASE_URL') ?: 'https://api.staging.two.inc';
26+
27+
$config = $this->createMock(ConfigRepository::class);
28+
$config->method('getCheckoutApiUrl')->willReturn($baseUrl);
29+
$config->method('addVersionDataInURL')->willReturnArgument(0);
30+
$config->method('getApiKey')->willReturn($apiKey);
31+
32+
$log = $this->createMock(LogRepository::class);
33+
34+
$this->adapter = new Adapter($config, new RealCurl(), $log);
35+
}
36+
37+
public function testApiKeyIsValid(): void
38+
{
39+
$result = $this->adapter->execute('/v1/merchant/verify_api_key', [], 'GET');
40+
41+
$this->assertIsArray($result);
42+
$this->assertArrayNotHasKey('error_code', $result);
43+
}
44+
45+
public function testInvalidApiKeyReturns401WithStructuredError(): void
46+
{
47+
$baseUrl = getenv('TWO_API_BASE_URL') ?: 'https://api.staging.two.inc';
48+
49+
$config = $this->createMock(ConfigRepository::class);
50+
$config->method('getCheckoutApiUrl')->willReturn($baseUrl);
51+
$config->method('addVersionDataInURL')->willReturnArgument(0);
52+
$config->method('getApiKey')->willReturn('invalid-key');
53+
54+
$log = $this->createMock(LogRepository::class);
55+
56+
$adapter = new Adapter($config, new RealCurl(), $log);
57+
$result = $adapter->execute('/v1/merchant/verify_api_key', [], 'GET');
58+
59+
$this->assertEquals(401, $result['http_status']);
60+
}
61+
62+
public function testBadOrderPayloadReturnsStructuredError(): void
63+
{
64+
$result = $this->adapter->execute('/v1/order', []);
65+
66+
$this->assertArrayHasKey('http_status', $result);
67+
$this->assertGreaterThanOrEqual(400, $result['http_status']);
68+
}
69+
}

Test/E2E/Http/RealCurl.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Two\Gateway\Test\E2E\Http;
5+
6+
use Magento\Framework\HTTP\Client\Curl;
7+
8+
/**
9+
* Real HTTP implementation of the Curl interface for E2E tests.
10+
* Mirrors the interface of the Magento Curl stub so it can be injected
11+
* into Service\Api\Adapter without modification.
12+
*/
13+
class RealCurl extends Curl
14+
{
15+
private array $headers = [];
16+
private array $options = [];
17+
private string $body = '';
18+
private int $status = 0;
19+
private array $responseHeaders = [];
20+
21+
public function addHeader(string $name, $value): void
22+
{
23+
$this->headers[] = "$name: $value";
24+
}
25+
26+
public function setOption(int $option, $value): void
27+
{
28+
$this->options[$option] = $value;
29+
}
30+
31+
public function post(string $url, $params): void
32+
{
33+
$this->doRequest($url, [
34+
CURLOPT_POST => true,
35+
CURLOPT_POSTFIELDS => $params,
36+
]);
37+
}
38+
39+
public function get(string $url): void
40+
{
41+
$this->doRequest($url, [
42+
CURLOPT_HTTPGET => true,
43+
]);
44+
}
45+
46+
public function getBody(): string
47+
{
48+
return $this->body;
49+
}
50+
51+
public function getStatus(): int
52+
{
53+
return $this->status;
54+
}
55+
56+
public function getHeaders(): array
57+
{
58+
return $this->responseHeaders;
59+
}
60+
61+
private function doRequest(string $url, array $extraOptions): void
62+
{
63+
$ch = curl_init($url);
64+
65+
$responseHeaders = [];
66+
$curlOptions = $this->options + $extraOptions + [
67+
CURLOPT_HTTPHEADER => $this->headers,
68+
CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$responseHeaders) {
69+
$len = strlen($header);
70+
$parts = explode(':', $header, 2);
71+
if (count($parts) === 2) {
72+
$responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
73+
}
74+
return $len;
75+
},
76+
];
77+
78+
curl_setopt_array($ch, $curlOptions);
79+
80+
$this->body = (string)(curl_exec($ch) ?: '');
81+
$this->status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
82+
$this->responseHeaders = $responseHeaders;
83+
84+
curl_close($ch);
85+
}
86+
}

Test/Stubs/BundlePrice.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Magento\Bundle\Model\Product;
5+
6+
/**
7+
* Minimal Price stub — provides the pricing-type constants
8+
* used by Service\Order::shouldSkip().
9+
*/
10+
class Price
11+
{
12+
public const PRICE_TYPE_FIXED = 0;
13+
public const PRICE_TYPE_DYNAMIC = 1;
14+
}

Test/Stubs/Curl.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Magento\Framework\HTTP\Client;
5+
6+
/**
7+
* Minimal Curl stub for unit tests.
8+
*
9+
* The catch-all autoloader creates an empty class, but PHPUnit can't
10+
* mock methods that don't exist. This stub declares the methods used
11+
* by Service\Api\Adapter.
12+
*/
13+
class Curl
14+
{
15+
public function addHeader(string $name, $value): void
16+
{
17+
}
18+
19+
public function setOption(int $option, $value): void
20+
{
21+
}
22+
23+
public function post(string $url, $params): void
24+
{
25+
}
26+
27+
public function get(string $url): void
28+
{
29+
}
30+
31+
public function getBody(): string
32+
{
33+
return '';
34+
}
35+
36+
public function getStatus(): int
37+
{
38+
return 0;
39+
}
40+
41+
public function getHeaders(): array
42+
{
43+
return [];
44+
}
45+
}

Test/Stubs/LocalizedException.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Magento\Framework\Exception;
5+
6+
use Magento\Framework\Phrase;
7+
8+
/**
9+
* Minimal LocalizedException stub for unit tests.
10+
*
11+
* Must extend \Exception so it's throwable. Constructor accepts a Phrase
12+
* and passes the rendered string to the parent Exception.
13+
*/
14+
class LocalizedException extends \Exception
15+
{
16+
/** @var Phrase */
17+
private $phrase;
18+
19+
public function __construct(Phrase $phrase, ?\Exception $cause = null, int $code = 0)
20+
{
21+
$this->phrase = $phrase;
22+
parent::__construct($phrase->render(), $code, $cause);
23+
}
24+
25+
public function getLogMessage(): string
26+
{
27+
return $this->phrase->render();
28+
}
29+
}

Test/Stubs/Phrase.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Magento\Framework;
5+
6+
/**
7+
* Minimal Phrase stub for unit tests.
8+
*
9+
* Supports %1, %2, … placeholder replacement matching Magento's
10+
* real Phrase behaviour.
11+
*/
12+
class Phrase
13+
{
14+
/** @var string */
15+
private $text;
16+
17+
/** @var array */
18+
private $arguments;
19+
20+
public function __construct(string $text, array $arguments = [])
21+
{
22+
$this->text = $text;
23+
$this->arguments = $arguments;
24+
}
25+
26+
public function render(): string
27+
{
28+
$result = $this->text;
29+
foreach ($this->arguments as $index => $value) {
30+
$result = str_replace('%' . ($index + 1), (string)$value, $result);
31+
}
32+
return $result;
33+
}
34+
35+
public function __toString(): string
36+
{
37+
return $this->render();
38+
}
39+
40+
public function getText(): string
41+
{
42+
return $this->text;
43+
}
44+
45+
public function getArguments(): array
46+
{
47+
return $this->arguments;
48+
}
49+
}

0 commit comments

Comments
 (0)