diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..f37feb1 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 5ce01ac..465beba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Makefile b/Makefile index 352f15f..129a671 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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/ diff --git a/Model/Two.php b/Model/Two.php index b5e6760..404708c 100755 --- a/Model/Two.php +++ b/Model/Two.php @@ -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 diff --git a/Test/E2E/ApiAdapterTest.php b/Test/E2E/ApiAdapterTest.php new file mode 100644 index 0000000..53b174d --- /dev/null +++ b/Test/E2E/ApiAdapterTest.php @@ -0,0 +1,69 @@ +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']); + } +} diff --git a/Test/E2E/Http/RealCurl.php b/Test/E2E/Http/RealCurl.php new file mode 100644 index 0000000..9ab60a7 --- /dev/null +++ b/Test/E2E/Http/RealCurl.php @@ -0,0 +1,86 @@ +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); + } +} diff --git a/Test/Stubs/BundlePrice.php b/Test/Stubs/BundlePrice.php new file mode 100644 index 0000000..a315cec --- /dev/null +++ b/Test/Stubs/BundlePrice.php @@ -0,0 +1,14 @@ +phrase = $phrase; + parent::__construct($phrase->render(), $code, $cause); + } + + public function getLogMessage(): string + { + return $this->phrase->render(); + } +} diff --git a/Test/Stubs/Phrase.php b/Test/Stubs/Phrase.php new file mode 100644 index 0000000..613cc86 --- /dev/null +++ b/Test/Stubs/Phrase.php @@ -0,0 +1,49 @@ +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; + } +} diff --git a/Test/Stubs/ProductType.php b/Test/Stubs/ProductType.php new file mode 100644 index 0000000..39dbf95 --- /dev/null +++ b/Test/Stubs/ProductType.php @@ -0,0 +1,13 @@ +mode; + } +} diff --git a/Test/Unit/Model/Config/RepositoryUrlTest.php b/Test/Unit/Model/Config/RepositoryUrlTest.php new file mode 100644 index 0000000..fdf1839 --- /dev/null +++ b/Test/Unit/Model/Config/RepositoryUrlTest.php @@ -0,0 +1,230 @@ +scopeConfig = $this->createMock(ScopeConfigInterface::class); + $encryptor = $this->createMock(EncryptorInterface::class); + $urlBuilder = $this->createMock(UrlInterface::class); + $productMetadata = $this->createMock(ProductMetadataInterface::class); + $this->appState = $this->createMock(State::class); + + $this->repository = new Repository( + $this->scopeConfig, + $encryptor, + $urlBuilder, + $productMetadata, + $this->appState + ); + } + + protected function tearDown(): void + { + foreach ($this->envVarsToClear as $var) { + putenv($var); + } + $this->envVarsToClear = []; + } + + private function setEnv(string $name, string $value): void + { + putenv("$name=$value"); + $this->envVarsToClear[] = $name; + } + + private function configureMode(string $mode): void + { + $this->scopeConfig + ->method('getValue') + ->willReturn($mode); + } + + // ── getCheckoutApiUrl ─────────────────────────────────────────────── + + public function testApiUrlProductionMode(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_PRODUCTION); + $this->configureMode('production'); + + $this->assertEquals('https://api.two.inc', $this->repository->getCheckoutApiUrl()); + } + + public function testApiUrlSandboxMode(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_PRODUCTION); + $this->configureMode('sandbox'); + + $this->assertEquals('https://api.sandbox.two.inc', $this->repository->getCheckoutApiUrl()); + } + + public function testApiUrlDeveloperModeWithEnvVar(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->setEnv('TWO_API_BASE_URL', 'http://localhost:8000'); + + $this->assertEquals('http://localhost:8000', $this->repository->getCheckoutApiUrl()); + } + + public function testApiUrlDeveloperModeEmptyEnvVar(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->setEnv('TWO_API_BASE_URL', ''); + $this->configureMode('sandbox'); + + $this->assertEquals('https://api.sandbox.two.inc', $this->repository->getCheckoutApiUrl()); + } + + public function testApiUrlNonDeveloperModeIgnoresEnvVar(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_PRODUCTION); + $this->setEnv('TWO_API_BASE_URL', 'http://localhost:8000'); + $this->configureMode('production'); + + $this->assertEquals('https://api.two.inc', $this->repository->getCheckoutApiUrl()); + } + + public function testApiUrlExplicitModeParameter(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_PRODUCTION); + + $this->assertEquals( + 'https://api.staging.two.inc', + $this->repository->getCheckoutApiUrl('staging') + ); + } + + public function testApiUrlDeveloperModeEnvVarOverridesExplicitMode(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->setEnv('TWO_API_BASE_URL', 'http://localhost:8000'); + + // Env var takes precedence over explicit $mode in developer mode + $this->assertEquals('http://localhost:8000', $this->repository->getCheckoutApiUrl('sandbox')); + } + + // ── getCheckoutPageUrl ────────────────────────────────────────────── + + public function testPageUrlProductionMode(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_PRODUCTION); + $this->configureMode('production'); + + $this->assertEquals('https://checkout.two.inc', $this->repository->getCheckoutPageUrl()); + } + + public function testPageUrlSandboxMode(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_PRODUCTION); + $this->configureMode('sandbox'); + + $this->assertEquals('https://checkout.sandbox.two.inc', $this->repository->getCheckoutPageUrl()); + } + + public function testPageUrlDeveloperModeWithEnvVar(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->setEnv('TWO_CHECKOUT_BASE_URL', 'http://localhost:3000'); + + $this->assertEquals('http://localhost:3000', $this->repository->getCheckoutPageUrl()); + } + + public function testPageUrlDeveloperModeEmptyEnvVar(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->setEnv('TWO_CHECKOUT_BASE_URL', ''); + $this->configureMode('sandbox'); + + $this->assertEquals('https://checkout.sandbox.two.inc', $this->repository->getCheckoutPageUrl()); + } + + public function testPageUrlNonDeveloperModeIgnoresEnvVar(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_PRODUCTION); + $this->setEnv('TWO_CHECKOUT_BASE_URL', 'http://localhost:3000'); + $this->configureMode('production'); + + $this->assertEquals('https://checkout.two.inc', $this->repository->getCheckoutPageUrl()); + } + + public function testPageUrlExplicitModeParameter(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_PRODUCTION); + + $this->assertEquals( + 'https://checkout.staging.two.inc', + $this->repository->getCheckoutPageUrl('staging') + ); + } + + public function testPageUrlDeveloperModeEnvVarOverridesExplicitMode(): void + { + $this->appState->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->setEnv('TWO_CHECKOUT_BASE_URL', 'http://localhost:3000'); + + // Env var takes precedence over explicit $mode in developer mode + $this->assertEquals('http://localhost:3000', $this->repository->getCheckoutPageUrl('sandbox')); + } + + // ── addVersionDataInURL ───────────────────────────────────────────── + + public function testAddVersionDataAppendsQueryStringToPlainUrl(): void + { + $this->scopeConfig->method('getValue')->willReturn('1.2.3'); + + $result = $this->repository->addVersionDataInURL('https://api.two.inc/v1/order'); + + $this->assertStringStartsWith('https://api.two.inc/v1/order?', $result); + $this->assertStringContainsString('client=Magento', $result); + $this->assertStringContainsString('client_v=1.2.3', $result); + } + + public function testAddVersionDataAppendsWithAmpersandWhenQueryExists(): void + { + $this->scopeConfig->method('getValue')->willReturn('1.2.3'); + + $result = $this->repository->addVersionDataInURL('https://api.two.inc/v1/order?foo=bar'); + + $this->assertStringContainsString('?foo=bar&', $result); + $this->assertStringContainsString('client=Magento', $result); + $this->assertStringContainsString('client_v=1.2.3', $result); + } + + public function testAddVersionDataWithNullVersionOmitsVersionParam(): void + { + $this->scopeConfig->method('getValue')->willReturn(null); + + $result = $this->repository->addVersionDataInURL('https://api.two.inc/v1/order'); + + $this->assertStringContainsString('client=Magento', $result); + // http_build_query omits null values entirely + $this->assertStringNotContainsString('client_v', $result); + } +} diff --git a/Test/Unit/Model/TwoErrorHandlingTest.php b/Test/Unit/Model/TwoErrorHandlingTest.php new file mode 100644 index 0000000..afec5af --- /dev/null +++ b/Test/Unit/Model/TwoErrorHandlingTest.php @@ -0,0 +1,288 @@ +model = $this->getMockBuilder(Two::class) + ->disableOriginalConstructor() + ->onlyMethods([]) // we test real implementations + ->getMock(); + + // Inject a configRepository so that PROVIDER constant is accessible. + $configRepo = $this->createMock(ConfigRepository::class); + $ref = new \ReflectionClass(Two::class); + $prop = $ref->getProperty('configRepository'); + $prop->setAccessible(true); + $prop->setValue($this->model, $configRepo); + } + + // ── getErrorFromResponse ──────────────────────────────────────────── + + public function testSuccessfulResponseReturnsNull(): void + { + $response = ['status' => 'APPROVED', 'id' => 'abc-123']; + $this->assertNull($this->model->getErrorFromResponse($response)); + } + + public function testEmptyResponseReturnsGeneralError(): void + { + $result = $this->model->getErrorFromResponse([]); + $this->assertInstanceOf(Phrase::class, $result); + $rendered = $result->render(); + $this->assertStringContainsString('Something went wrong', $rendered); + $this->assertStringContainsString('Two', $rendered); + } + + // ── Validation errors (400 + error_json) ──────────────────────────── + + public function testValidationErrorWithKnownFieldAndMessage(): void + { + $response = [ + 'http_status' => 400, + 'error_json' => [ + [ + 'loc' => ['buyer', 'representative', 'phone_number'], + 'msg' => 'Invalid phone number', + ], + ], + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + $this->assertStringContainsString('Phone Number', $rendered); + $this->assertStringContainsString('Invalid phone number', $rendered); + // Validation errors must NOT include a trace ID + $this->assertStringNotContainsString('Trace ID', $rendered); + } + + public function testValidationErrorFieldOnlyNoMessage(): void + { + $response = [ + 'http_status' => 400, + 'error_json' => [ + ['loc' => ['buyer', 'representative', 'email']], + ], + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + $this->assertStringContainsString('Email Address', $rendered); + $this->assertStringContainsString('is not valid', $rendered); + } + + public function testValidationErrorUnknownLocWithMessage(): void + { + $response = [ + 'http_status' => 400, + 'error_json' => [ + ['loc' => ['some', 'unknown', 'field'], 'msg' => 'Bad value'], + ], + ]; + $result = $this->model->getErrorFromResponse($response); + $this->assertStringContainsString('Bad value', $result->render()); + } + + public function testValidationErrorMultipleErrors(): void + { + $response = [ + 'http_status' => 400, + 'error_json' => [ + ['loc' => ['buyer', 'representative', 'first_name'], 'msg' => 'Required'], + ['loc' => ['buyer', 'representative', 'last_name'], 'msg' => 'Required'], + ], + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + $this->assertStringContainsString('First Name', $rendered); + $this->assertStringContainsString('Last Name', $rendered); + } + + public function testValidationErrorCleansPydanticPrefix(): void + { + $response = [ + 'http_status' => 400, + 'error_json' => [ + ['loc' => ['some', 'field'], 'msg' => 'Value error, bad input'], + ], + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + $this->assertStringNotContainsString('Value error,', $rendered); + $this->assertStringContainsString('bad input', $rendered); + } + + public function testValidationErrorCleansPydanticSuffix(): void + { + $response = [ + 'http_status' => 400, + 'error_json' => [ + ['loc' => ['some', 'field'], 'msg' => 'bad value [type=value_error]'], + ], + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + $this->assertStringNotContainsString('[type=', $rendered); + $this->assertStringContainsString('bad value', $rendered); + } + + public function testValidationErrorNoDoublePeriods(): void + { + $response = [ + 'http_status' => 400, + 'error_json' => [ + [ + 'loc' => ['buyer', 'representative', 'phone_number'], + 'msg' => 'Invalid phone number.', + ], + ], + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + // rtrim(msg, '.') then append '.' → single period + $this->assertStringNotContainsString('..', $rendered); + } + + // ── User errors (400 + error_code) ────────────────────────────────── + + public function testUserErrorSchemaError(): void + { + $response = [ + 'http_status' => 400, + 'error_code' => 'SCHEMA_ERROR', + 'error_message' => 'Missing required field: company_id', + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + $this->assertStringContainsString('Missing required field', $rendered); + $this->assertStringNotContainsString('Trace ID', $rendered); + } + + public function testUserErrorSameBuyerSeller(): void + { + $response = [ + 'http_status' => 400, + 'error_code' => 'SAME_BUYER_SELLER_ERROR', + 'error_message' => 'original api message', + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + $this->assertStringContainsString('buyer and the seller are the same', $rendered); + $this->assertStringNotContainsString('Trace ID', $rendered); + } + + public function testUserErrorOrderInvalid(): void + { + $response = [ + 'http_status' => 400, + 'error_code' => 'ORDER_INVALID', + 'error_message' => 'Order amount too low', + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + $this->assertStringContainsString('Order amount too low', $rendered); + $this->assertStringNotContainsString('Trace ID', $rendered); + } + + // ── System errors (non-400 + error_code) ──────────────────────────── + + public function testSystemErrorWithTraceId(): void + { + $response = [ + 'http_status' => 500, + 'error_code' => 'INTERNAL_ERROR', + 'error_message' => 'Something broke', + 'error_trace_id' => 'abc-123-trace', + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + $this->assertStringContainsString('failed', $rendered); + $this->assertStringContainsString('Something broke', $rendered); + $this->assertStringContainsString('abc-123-trace', $rendered); + $this->assertStringContainsString('Trace ID', $rendered); + } + + public function testSystemErrorWithoutTraceId(): void + { + $response = [ + 'http_status' => 500, + 'error_code' => 'INTERNAL_ERROR', + 'error_message' => 'Something broke', + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + $this->assertStringContainsString('failed', $rendered); + $this->assertStringNotContainsString('Trace ID', $rendered); + $this->assertStringNotContainsString('[', $rendered); + } + + public function testNon400WithErrorJsonSkipsValidationPath(): void + { + $response = [ + 'http_status' => 500, + 'error_json' => [ + ['loc' => ['buyer', 'representative', 'email'], 'msg' => 'bad'], + ], + 'error_code' => 'INTERNAL_ERROR', + 'error_message' => 'Server error', + ]; + $result = $this->model->getErrorFromResponse($response); + $rendered = $result->render(); + // Should hit the system-error path, not validation + $this->assertStringContainsString('failed', $rendered); + $this->assertStringNotContainsString('Email Address', $rendered); + } + + // ── getFieldNameFromLoc ───────────────────────────────────────────── + + /** + * @dataProvider knownFieldMappingsProvider + */ + public function testKnownFieldMappings(string $locJson, string $expectedField): void + { + $result = $this->model->getFieldNameFromLoc($locJson); + $this->assertNotNull($result); + $this->assertEquals($expectedField, $result->render()); + } + + public function knownFieldMappingsProvider(): array + { + return [ + 'phone' => ['["buyer","representative","phone_number"]', 'Phone Number'], + 'org_num' => ['["buyer","company","organization_number"]', 'Company ID'], + 'fname' => ['["buyer","representative","first_name"]', 'First Name'], + 'lname' => ['["buyer","representative","last_name"]', 'Last Name'], + 'email' => ['["buyer","representative","email"]', 'Email Address'], + 'street' => ['["billing_address","street_address"]', 'Street Address'], + 'city' => ['["billing_address","city"]', 'City'], + 'country' => ['["billing_address","country"]', 'Country'], + 'postcode' => ['["billing_address","postal_code"]', 'Zip/Postal Code'], + ]; + } + + public function testUnknownLocReturnsNull(): void + { + $this->assertNull($this->model->getFieldNameFromLoc('["unknown","field"]')); + } + + public function testWhitespaceInLocIsNormalised(): void + { + $locWithSpaces = '[ "buyer" , "representative" , "phone_number" ]'; + $result = $this->model->getFieldNameFromLoc($locWithSpaces); + $this->assertNotNull($result); + $this->assertEquals('Phone Number', $result->render()); + } +} diff --git a/Test/Unit/Service/Api/AdapterTest.php b/Test/Unit/Service/Api/AdapterTest.php new file mode 100644 index 0000000..3e922f3 --- /dev/null +++ b/Test/Unit/Service/Api/AdapterTest.php @@ -0,0 +1,172 @@ +configRepository = $this->createMock(ConfigRepository::class); + $this->curl = $this->createMock(Curl::class); + $this->logRepository = $this->createMock(LogRepository::class); + + $this->configRepository->method('getCheckoutApiUrl')->willReturn('https://api.two.inc'); + $this->configRepository->method('addVersionDataInURL')->willReturnArgument(0); + $this->configRepository->method('getApiKey')->willReturn('test-key'); + + $this->adapter = new Adapter( + $this->configRepository, + $this->curl, + $this->logRepository + ); + } + + // ── 2xx responses ─────────────────────────────────────────────────── + + public function testSuccessfulPostReturnsDecodedJson(): void + { + $this->curl->method('getStatus')->willReturn(200); + $this->curl->method('getBody')->willReturn('{"id":"abc"}'); + + $result = $this->adapter->execute('/v1/order', ['amount' => 100]); + + $this->assertEquals(['id' => 'abc'], $result); + } + + public function testGetRoutesThoughGetMethod(): void + { + $this->curl->method('getStatus')->willReturn(200); + $this->curl->method('getBody')->willReturn('{"ok":true}'); + + $this->curl->expects($this->once())->method('get'); + $this->curl->expects($this->never())->method('post'); + + $result = $this->adapter->execute('/v1/order/123', [], 'GET'); + + $this->assertEquals(['ok' => true], $result); + } + + public function testSuccessEmptyBodyNonTokenEndpoint(): void + { + $this->curl->method('getStatus')->willReturn(200); + $this->curl->method('getBody')->willReturn(''); + + $result = $this->adapter->execute('/v1/order', ['foo' => 'bar']); + + $this->assertEquals([], $result); + } + + public function testSuccessEmptyBodyTokenEndpointReturnsHeaders(): void + { + $this->curl->method('getStatus')->willReturn(200); + $this->curl->method('getBody')->willReturn(''); + $this->curl->method('getHeaders')->willReturn([ + 'X-Delegation-Token' => 'abc123', + 'Content-Type' => 'application/json', + ]); + + $result = $this->adapter->execute('/registry/v1/delegation'); + + $this->assertArrayHasKey('x-delegation-token', $result); + $this->assertEquals('abc123', $result['x-delegation-token']); + } + + // ── Non-2xx responses (ABN-287 critical) ──────────────────────────── + + public function testNon2xxWithBodyReturnsJsonPlusHttpStatus(): void + { + $this->curl->method('getStatus')->willReturn(422); + $this->curl->method('getBody')->willReturn( + '{"error_code":"VALIDATION_ERROR","error_message":"Invalid field"}' + ); + + $result = $this->adapter->execute('/v1/order', ['amount' => 100]); + + $this->assertEquals('VALIDATION_ERROR', $result['error_code']); + $this->assertEquals('Invalid field', $result['error_message']); + $this->assertEquals(422, $result['http_status']); + } + + public function testNon2xxWithMalformedJsonBody(): void + { + $this->curl->method('getStatus')->willReturn(500); + $this->curl->method('getBody')->willReturn('not json'); + + $result = $this->adapter->execute('/v1/order', ['amount' => 100]); + + $this->assertEquals(500, $result['http_status']); + } + + public function testNon2xxWithEmptyBodyReturnsCaughtException(): void + { + $this->curl->method('getStatus')->willReturn(500); + $this->curl->method('getBody')->willReturn(''); + + $result = $this->adapter->execute('/v1/order', ['amount' => 100]); + + $this->assertEquals(400, $result['error_code']); + $this->assertStringContainsString('Invalid API response from Two.', $result['error_message']); + } + + // ── Edge cases ────────────────────────────────────────────────────── + + public function testPostWithEmptyPayloadSendsEmptyString(): void + { + $this->curl->method('getStatus')->willReturn(200); + $this->curl->method('getBody')->willReturn('[]'); + + $this->curl->expects($this->once()) + ->method('post') + ->with($this->anything(), ''); + + $this->adapter->execute('/v1/order', []); + } + + public function testPutRoutesThoughPostBranch(): void + { + $this->curl->method('getStatus')->willReturn(200); + $this->curl->method('getBody')->willReturn('{}'); + + $this->curl->expects($this->once())->method('post'); + $this->curl->expects($this->never())->method('get'); + + $this->adapter->execute('/v1/order/123', ['status' => 'fulfilled'], 'PUT'); + } + + public function testExceptionDuringRequestReturnsCaughtError(): void + { + $this->configRepository = $this->createMock(ConfigRepository::class); + $this->configRepository->method('getCheckoutApiUrl') + ->willThrowException(new \RuntimeException('Connection failed')); + + $adapter = new Adapter( + $this->configRepository, + $this->curl, + $this->logRepository + ); + + $result = $adapter->execute('/v1/order'); + + $this->assertEquals(400, $result['error_code']); + $this->assertEquals('Connection failed', $result['error_message']); + } +} diff --git a/Test/Unit/Service/OrderShouldSkipTest.php b/Test/Unit/Service/OrderShouldSkipTest.php new file mode 100644 index 0000000..343b3d6 --- /dev/null +++ b/Test/Unit/Service/OrderShouldSkipTest.php @@ -0,0 +1,132 @@ +orderService = $this->getMockForAbstractClass( + Order::class, + [], + '', + false // don't call constructor + ); + } + + /** + * Create a mock item with getProductType() and getProduct()->getPriceType(). + */ + private function makeItem(string $productType, ?int $priceType = null): object + { + $item = new \stdClass(); + $item->productType = $productType; + $item->priceType = $priceType; + + // We need an object with getProductType() and getProduct() methods. + // Use an anonymous class for clean mocking. + return new class($productType, $priceType) { + private $productType; + private $priceType; + + public function __construct(string $productType, ?int $priceType) + { + $this->productType = $productType; + $this->priceType = $priceType; + } + + public function getProductType(): string + { + return $this->productType; + } + + public function getProduct(): object + { + $priceType = $this->priceType; + return new class($priceType) { + private $priceType; + + public function __construct(?int $priceType) + { + $this->priceType = $priceType; + } + + public function getPriceType(): ?int + { + return $this->priceType; + } + }; + } + }; + } + + // ── Bundle items (no parent) ──────────────────────────────────────── + + public function testBundleDynamicPricingIsSkipped(): void + { + $item = $this->makeItem(Type::TYPE_BUNDLE, Price::PRICE_TYPE_DYNAMIC); + + $this->assertTrue($this->orderService->shouldSkip(null, $item)); + } + + public function testBundleFixedPricingIsNotSkipped(): void + { + $item = $this->makeItem(Type::TYPE_BUNDLE, Price::PRICE_TYPE_FIXED); + + $this->assertFalse($this->orderService->shouldSkip(null, $item)); + } + + // ── Simple items (no parent) ──────────────────────────────────────── + + public function testSimpleItemNoParentIsNotSkipped(): void + { + $item = $this->makeItem('simple'); + + $this->assertFalse($this->orderService->shouldSkip(null, $item)); + } + + // ── Child items with non-bundle parent ────────────────────────────── + + public function testChildOfConfigurableIsSkipped(): void + { + $parent = $this->makeItem('configurable'); + $item = $this->makeItem('simple'); + + $this->assertTrue($this->orderService->shouldSkip($parent, $item)); + } + + public function testChildOfGroupedIsSkipped(): void + { + $parent = $this->makeItem('grouped'); + $item = $this->makeItem('simple'); + + $this->assertTrue($this->orderService->shouldSkip($parent, $item)); + } + + // ── Child items with bundle parent ────────────────────────────────── + + public function testChildOfBundleFixedPriceIsSkipped(): void + { + $parent = $this->makeItem(Type::TYPE_BUNDLE, Price::PRICE_TYPE_FIXED); + $item = $this->makeItem('simple'); + + $this->assertTrue($this->orderService->shouldSkip($parent, $item)); + } + + public function testChildOfBundleDynamicPriceIsNotSkipped(): void + { + $parent = $this->makeItem(Type::TYPE_BUNDLE, Price::PRICE_TYPE_DYNAMIC); + $item = $this->makeItem('simple'); + + $this->assertFalse($this->orderService->shouldSkip($parent, $item)); + } +} diff --git a/Test/bootstrap.php b/Test/bootstrap.php new file mode 100644 index 0000000..aebfbd3 --- /dev/null +++ b/Test/bootstrap.php @@ -0,0 +1,75 @@ + + + + + Test/Unit/ + + + Test/E2E/ + + +