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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: PHPUnit

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['7.4', '8.1', '8.2']

steps:
- uses: actions/checkout@v4

- name: Set up PHP ${{ matrix.php-version }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}

- name: Download PHPUnit
env:
PHPUNIT_VERSION: '9.6.34'
PHPUNIT_SHA256: 'e7264ae61fe58a487c2bd741905b85940d8fbc2b32cf4a279949b6d9a172a06a'
run: |
php -r "copy('https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar', 'phpunit.phar');"
echo "${PHPUNIT_SHA256} phpunit.phar" | sha256sum -c -

- name: Run tests
run: php phpunit.phar
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ bin/magento cache:flush
# Create pub/opcache-clear.php or restart PHP-FPM
```

## Session Artifacts

This is a **public repository**. Do not commit session-specific content such as:
- Session summaries or transcripts
- Implementation plans or review notes
- Any file under `docs/` that contains conversation context

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.

### Common Issues

1. **Template not found error**: Run `setup:di:compile` and clear opcache
Expand Down
22 changes: 21 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ TWO_API_BASE_URL ?= https://api.staging.two.inc
TWO_CHECKOUT_BASE_URL ?= https://checkout.staging.two.inc
TWO_STORE_COUNTRY ?= NO

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

.DEFAULT_GOAL := help

Expand Down Expand Up @@ -94,6 +94,26 @@ patch: bumpver-patch
minor: bumpver-minor
## Bump major version
major: bumpver-major
PHPUNIT_VERSION := 9.6.34
PHPUNIT_SHA256 := e7264ae61fe58a487c2bd741905b85940d8fbc2b32cf4a279949b6d9a172a06a

## Run PHPUnit tests
test:
docker run --rm -v $(CURDIR):/app -w /app php:8.1-cli bash -c \
"php -r \"copy('https://phar.phpunit.de/phpunit-$(PHPUNIT_VERSION).phar', '/tmp/phpunit.phar');\" \
&& echo '$(PHPUNIT_SHA256) /tmp/phpunit.phar' | sha256sum -c - \
&& php /tmp/phpunit.phar"

## Run end-to-end API tests (requires TWO_API_KEY)
test-e2e:
docker run --rm -v $(CURDIR):/app -w /app \
-e TWO_API_KEY=$(TWO_API_KEY) \
-e TWO_API_BASE_URL=$(TWO_API_BASE_URL) \
php:8.1-cli bash -c \
"php -r \"copy('https://phar.phpunit.de/phpunit-$(PHPUNIT_VERSION).phar', '/tmp/phpunit.phar');\" \
&& echo '$(PHPUNIT_SHA256) /tmp/phpunit.phar' | sha256sum -c - \
&& php /tmp/phpunit.phar --testsuite E2E"

## Format frontend assets with Prettier
format:
prettier -w view/frontend/web/js/
Expand Down
2 changes: 1 addition & 1 deletion Model/Two.php
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ public function getErrorFromResponse(array $response): ?Phrase
$reason = __('The buyer and the seller are the same company.');
}
if ($isClientError && in_array($errorCode, ['SCHEMA_ERROR', 'SAME_BUYER_SELLER_ERROR', 'ORDER_INVALID'])) {
return $reason;
return $reason instanceof Phrase ? $reason : __($reason);
}

// System errors — include trace ID
Expand Down
69 changes: 69 additions & 0 deletions Test/E2E/ApiAdapterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);

namespace Two\Gateway\Test\E2E\Service\Api;

use PHPUnit\Framework\TestCase;
use Two\Gateway\Api\Config\RepositoryInterface as ConfigRepository;
use Two\Gateway\Api\Log\RepositoryInterface as LogRepository;
use Two\Gateway\Service\Api\Adapter;
use Two\Gateway\Test\E2E\Http\RealCurl;

/**
* End-to-end tests for Service\Api\Adapter against the real Two API.
* Defaults to staging unless TWO_API_BASE_URL overrides it.
*
* Run via: TWO_API_KEY=xxx make test-e2e
*/
class ApiAdapterTest extends TestCase
{
private Adapter $adapter;

protected function setUp(): void
{
$apiKey = (string)getenv('TWO_API_KEY');
$baseUrl = getenv('TWO_API_BASE_URL') ?: 'https://api.staging.two.inc';

$config = $this->createMock(ConfigRepository::class);
$config->method('getCheckoutApiUrl')->willReturn($baseUrl);
$config->method('addVersionDataInURL')->willReturnArgument(0);
$config->method('getApiKey')->willReturn($apiKey);

$log = $this->createMock(LogRepository::class);

$this->adapter = new Adapter($config, new RealCurl(), $log);
}

public function testApiKeyIsValid(): void
{
$result = $this->adapter->execute('/v1/merchant/verify_api_key', [], 'GET');

$this->assertIsArray($result);
$this->assertArrayNotHasKey('error_code', $result);
}

public function testInvalidApiKeyReturns401WithStructuredError(): void
{
$baseUrl = getenv('TWO_API_BASE_URL') ?: 'https://api.staging.two.inc';

$config = $this->createMock(ConfigRepository::class);
$config->method('getCheckoutApiUrl')->willReturn($baseUrl);
$config->method('addVersionDataInURL')->willReturnArgument(0);
$config->method('getApiKey')->willReturn('invalid-key');

$log = $this->createMock(LogRepository::class);

$adapter = new Adapter($config, new RealCurl(), $log);
$result = $adapter->execute('/v1/merchant/verify_api_key', [], 'GET');

$this->assertEquals(401, $result['http_status']);
}

public function testBadOrderPayloadReturnsStructuredError(): void
{
$result = $this->adapter->execute('/v1/order', []);

$this->assertArrayHasKey('http_status', $result);
$this->assertGreaterThanOrEqual(400, $result['http_status']);
}
}
86 changes: 86 additions & 0 deletions Test/E2E/Http/RealCurl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);

namespace Two\Gateway\Test\E2E\Http;

use Magento\Framework\HTTP\Client\Curl;

/**
* Real HTTP implementation of the Curl interface for E2E tests.
* Mirrors the interface of the Magento Curl stub so it can be injected
* into Service\Api\Adapter without modification.
*/
class RealCurl extends Curl
{
private array $headers = [];
private array $options = [];
private string $body = '';
private int $status = 0;
private array $responseHeaders = [];

public function addHeader(string $name, $value): void
{
$this->headers[] = "$name: $value";
}

public function setOption(int $option, $value): void
{
$this->options[$option] = $value;
}

public function post(string $url, $params): void
{
$this->doRequest($url, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $params,
]);
}

public function get(string $url): void
{
$this->doRequest($url, [
CURLOPT_HTTPGET => true,
]);
}

public function getBody(): string
{
return $this->body;
}

public function getStatus(): int
{
return $this->status;
}

public function getHeaders(): array
{
return $this->responseHeaders;
}

private function doRequest(string $url, array $extraOptions): void
{
$ch = curl_init($url);

$responseHeaders = [];
$curlOptions = $this->options + $extraOptions + [
CURLOPT_HTTPHEADER => $this->headers,
CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$responseHeaders) {
$len = strlen($header);
$parts = explode(':', $header, 2);
if (count($parts) === 2) {
$responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return $len;
},
];

curl_setopt_array($ch, $curlOptions);

$this->body = (string)(curl_exec($ch) ?: '');
$this->status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$this->responseHeaders = $responseHeaders;

curl_close($ch);
}
}
14 changes: 14 additions & 0 deletions Test/Stubs/BundlePrice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);

namespace Magento\Bundle\Model\Product;

/**
* Minimal Price stub — provides the pricing-type constants
* used by Service\Order::shouldSkip().
*/
class Price
{
public const PRICE_TYPE_FIXED = 0;
public const PRICE_TYPE_DYNAMIC = 1;
}
45 changes: 45 additions & 0 deletions Test/Stubs/Curl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);

namespace Magento\Framework\HTTP\Client;

/**
* Minimal Curl stub for unit tests.
*
* The catch-all autoloader creates an empty class, but PHPUnit can't
* mock methods that don't exist. This stub declares the methods used
* by Service\Api\Adapter.
*/
class Curl
{
public function addHeader(string $name, $value): void
{
}

public function setOption(int $option, $value): void
{
}

public function post(string $url, $params): void
{
}

public function get(string $url): void
{
}

public function getBody(): string
{
return '';
}

public function getStatus(): int
{
return 0;
}

public function getHeaders(): array
{
return [];
}
}
29 changes: 29 additions & 0 deletions Test/Stubs/LocalizedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);

namespace Magento\Framework\Exception;

use Magento\Framework\Phrase;

/**
* Minimal LocalizedException stub for unit tests.
*
* Must extend \Exception so it's throwable. Constructor accepts a Phrase
* and passes the rendered string to the parent Exception.
*/
class LocalizedException extends \Exception
{
/** @var Phrase */
private $phrase;

public function __construct(Phrase $phrase, ?\Exception $cause = null, int $code = 0)
{
$this->phrase = $phrase;
parent::__construct($phrase->render(), $code, $cause);
}

public function getLogMessage(): string
{
return $this->phrase->render();
}
}
49 changes: 49 additions & 0 deletions Test/Stubs/Phrase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);

namespace Magento\Framework;

/**
* Minimal Phrase stub for unit tests.
*
* Supports %1, %2, … placeholder replacement matching Magento's
* real Phrase behaviour.
*/
class Phrase
{
/** @var string */
private $text;

/** @var array */
private $arguments;

public function __construct(string $text, array $arguments = [])
{
$this->text = $text;
$this->arguments = $arguments;
}

public function render(): string
{
$result = $this->text;
foreach ($this->arguments as $index => $value) {
$result = str_replace('%' . ($index + 1), (string)$value, $result);
}
return $result;
}

public function __toString(): string
{
return $this->render();
}

public function getText(): string
{
return $this->text;
}

public function getArguments(): array
{
return $this->arguments;
}
}
Loading
Loading