From 09e9970efc25f05b0a532db280fbf92315f067dc Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Sat, 18 Jul 2026 04:21:09 +0100 Subject: [PATCH] Modernize postcode service integrations --- .coveralls.yml | 3 - .github/workflows/tests.yml | 29 ++++++ .travis.yml | 12 --- README.md | 38 +++++++- composer.json | 6 +- src/Objects/IdealPostcodes.php | 51 ++++++---- src/Objects/PostcodeAnywhere.php | 142 ++++++++++++++++++++++------ src/Objects/PostcodesIo.php | 35 ++++--- src/Utils/Tokenizer.php | 4 +- src/Utils/Validator.php | 37 +++++++- tests/Unit/BasicUsageTest.php | 18 +++- tests/Unit/PostcodeServicesTest.php | 118 +++++++++++++++++++++++ tests/Unit/PostcodesIoTest.php | 38 -------- 13 files changed, 400 insertions(+), 131 deletions(-) delete mode 100644 .coveralls.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml create mode 100644 tests/Unit/PostcodeServicesTest.php delete mode 100644 tests/Unit/PostcodesIoTest.php diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index dd9e7b8..0000000 --- a/.coveralls.yml +++ /dev/null @@ -1,3 +0,0 @@ -coverage_clover: tests/Logs/clover.xml -json_path: tests/Logs/coveralls-upload.json -service_name: travis-ci \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4afd7d6 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.1', '8.5'] + + steps: + - uses: actions/checkout@v5 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:${{ matrix.php == '7.1' && 'v2.2' || 'latest' }} + + - run: composer update --prefer-dist --no-interaction --no-progress + - run: composer validate --strict + - run: vendor/bin/phpunit diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ceb6e00..0000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: php -php: - - '7.1' - - '7.2' - - '7.3' - - '7.4' -install: - - composer install -script: - - ./vendor/bin/phpunit --coverage-clover ./tests/Logs/clover.xml -after_script: - - php vendor/bin/php-coveralls -v diff --git a/README.md b/README.md index 9c7a59e..338fe9e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # PHP Postcodes -[![Build Status](https://travis-ci.com/Jord-JD/php-postcodes.svg?branch=master)](https://travis-ci.com/Jord-JD/php-postcodes) -[![Coverage Status](https://coveralls.io/repos/github/Jord-JD/php-postcodes/badge.svg?branch=master)](https://coveralls.io/github/Jord-JD/php-postcodes?branch=master) +[![Tests](https://github.com/Jord-JD/php-postcodes/actions/workflows/tests.yml/badge.svg)](https://github.com/Jord-JD/php-postcodes/actions/workflows/tests.yml) [![Packagist](https://img.shields.io/packagist/dt/jord-jd/php-postcodes.svg)](https://packagist.org/packages/jord-jd/php-postcodes/stats) This library handles various UK postcode related tasks. @@ -26,9 +25,9 @@ To install, just run the following composer command. Using some of the data retrieval features provided by this library requires a postcode lookup service. It currently supports the following postcode lookup services. -* Ideal Postcodes - https://ideal-postcodes.co.uk -* Postcode Anywhere (PCA Predict) - https://www.pcapredict.com/ -* Postcodes.io - http://postcodes.io/ +* [Ideal Postcodes](https://ideal-postcodes.co.uk) +* [Loqate](https://www.loqate.com/) (the provider formerly known as Postcode Anywhere/PCA Predict) +* [Postcodes.io](https://postcodes.io/) Ideal Postcodes and Postcode Anywhere can return individual premises. Postcodes.io only provides postcode-level geographic and administrative data, so its `getAddressesByPostcode()` implementation returns a single `Address` object for the postcode rather than a list of premises. @@ -68,6 +67,14 @@ $validated = \JordJD\Postcodes\Utils\Validator::validatePostcode('ST163DP'); Please note that the postcode validation is case insensitive. +You can also normalize user input to the conventional uppercase format. Invalid +non-string or incomplete input returns `null`. + +```php +$postcode = \JordJD\Postcodes\Utils\Validator::normalizePostcode(" sw1a\t2aa "); +// SW1A 2AA +``` + ### Generate postcode This library allows you generate a random, valid UK postcode. This makes use of the @@ -85,3 +92,24 @@ $postcode = \JordJD\Postcodes\Utils\Generator::generatePostcode(); $outwardCode = \JordJD\Postcodes\Utils\Tokenizer::outward('ST163DP'); // Returns ST16 $inwardCode = \JordJD\Postcodes\Utils\Tokenizer::inward('ST163DP'); // Returns 3DP ``` + +## HTTP clients and errors + +Ideal Postcodes, Loqate and Postcodes.io use their current HTTPS JSON APIs. Each +service accepts an optional Guzzle `ClientInterface` implementation as its +second constructor argument, which is useful for custom timeouts, proxies, +logging and tests. + +```php +$client = new \GuzzleHttp\Client(['timeout' => 10]); +$service = new \JordJD\Postcodes\Objects\IdealPostcodes('API_KEY', $client); +``` + +Invalid postcodes throw `InvalidPostcodeException` before any API request is +made. Provider authentication, HTTP and response errors continue to throw an +exception with a descriptive message. + +## Compatibility + +PHP 7.1 through the current PHP 8.x releases are supported. Composer selects a +compatible maintained Guzzle and test-tool version for the PHP runtime in use. diff --git a/composer.json b/composer.json index 868fd49..c061bf3 100644 --- a/composer.json +++ b/composer.json @@ -13,12 +13,12 @@ "license": "LGPL-3.0-only", "require": { "php": "^7.1||^8.0", - "guzzlehttp/guzzle": "^6.0||^7.0", + "guzzlehttp/guzzle": "^6.5.8||^7.4.5", "fakerphp/faker": "^1.9.1", "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^7.5||^9.6||^10.5||^11.5||^12.5" }, "autoload": { "psr-4": { @@ -35,7 +35,7 @@ }, "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.1-dev" } } } diff --git a/src/Objects/IdealPostcodes.php b/src/Objects/IdealPostcodes.php index 37fc077..3758dc5 100644 --- a/src/Objects/IdealPostcodes.php +++ b/src/Objects/IdealPostcodes.php @@ -3,16 +3,19 @@ namespace JordJD\Postcodes\Objects; use JordJD\Postcodes\Interfaces\PostcodeServiceInterface; +use JordJD\Postcodes\Exceptions\InvalidPostcodeException; +use JordJD\Postcodes\Utils\Validator; use Exception; use GuzzleHttp\Client; -use GuzzleHttp\Psr7\Response; +use GuzzleHttp\ClientInterface; +use Psr\Http\Message\ResponseInterface; class IdealPostcodes implements PostcodeServiceInterface { private $apiKey = null; private $client = null; - public function __construct($apiKey) + public function __construct($apiKey, ClientInterface $client = null) { if (!$apiKey) { throw new Exception('No Ideal Postcodes API key specified.'); @@ -20,29 +23,39 @@ public function __construct($apiKey) $this->apiKey = $apiKey; - $headers = ['Authorization' => 'IDEALPOSTCODES api_key="'.$this->apiKey.'"']; - - $this->client = new Client(['base_uri' => 'https://api.ideal-postcodes.co.uk/v1/', 'timeout' => 3.0, 'headers' => $headers]); + $this->client = $client ?: new Client(['timeout' => 3.0]); } public function getAddressesByPostcode($postcode) { - $response = $this->client->request('GET', 'postcodes/'.$postcode); + if (!Validator::validatePostcode($postcode)) { + throw new InvalidPostcodeException('Post code provided is not valid'); + } + + $postcode = str_replace(' ', '', Validator::normalizePostcode($postcode)); + $response = $this->client->request( + 'GET', + 'https://api.ideal-postcodes.co.uk/v1/postcodes/'.rawurlencode($postcode), + [ + 'headers' => ['Authorization' => 'api_key="'.$this->apiKey.'"'], + 'http_errors' => false, + ] + ); $result = $this->parseResponse($response); return $result; } - private function parseResponse(Response $response) + private function parseResponse(ResponseInterface $response) { if ($response->getStatusCode() != 200) { throw new Exception('HTTP response code was not 200. Received HTTP reponse code: '.$response->getStatusCode().' ('.$response->getReasonPhrase().')'); } - $object = json_decode($response->getBody()); + $object = json_decode((string) $response->getBody()); - if (!$object) { + if (!is_object($object)) { throw new Exception('Response JSON could not be decoded.'); } @@ -62,16 +75,16 @@ private function parseResponse(Response $response) foreach ($object->result as $idealPostcodesAddress) { $address = new Address(); - $address->companyName = $idealPostcodesAddress->organisation_name; - $address->line1 = $idealPostcodesAddress->line_1; - $address->line2 = $idealPostcodesAddress->line_2; - $address->line3 = $idealPostcodesAddress->line_3; - $address->townCity = $idealPostcodesAddress->post_town; - $address->county = $idealPostcodesAddress->county; - $address->country = $idealPostcodesAddress->country; - $address->postcode = $idealPostcodesAddress->postcode; - $address->longitude = $idealPostcodesAddress->longitude; - $address->latitude = $idealPostcodesAddress->latitude; + $address->companyName = isset($idealPostcodesAddress->organisation_name) ? $idealPostcodesAddress->organisation_name : ''; + $address->line1 = isset($idealPostcodesAddress->line_1) ? $idealPostcodesAddress->line_1 : ''; + $address->line2 = isset($idealPostcodesAddress->line_2) ? $idealPostcodesAddress->line_2 : ''; + $address->line3 = isset($idealPostcodesAddress->line_3) ? $idealPostcodesAddress->line_3 : ''; + $address->townCity = isset($idealPostcodesAddress->post_town) ? $idealPostcodesAddress->post_town : ''; + $address->county = isset($idealPostcodesAddress->county) ? $idealPostcodesAddress->county : ''; + $address->country = isset($idealPostcodesAddress->country) ? $idealPostcodesAddress->country : 'United Kingdom'; + $address->postcode = isset($idealPostcodesAddress->postcode) ? $idealPostcodesAddress->postcode : ''; + $address->longitude = isset($idealPostcodesAddress->longitude) ? $idealPostcodesAddress->longitude : null; + $address->latitude = isset($idealPostcodesAddress->latitude) ? $idealPostcodesAddress->latitude : null; $addresses[] = $address; } diff --git a/src/Objects/PostcodeAnywhere.php b/src/Objects/PostcodeAnywhere.php index fd2819a..054e48e 100644 --- a/src/Objects/PostcodeAnywhere.php +++ b/src/Objects/PostcodeAnywhere.php @@ -3,16 +3,19 @@ namespace JordJD\Postcodes\Objects; use JordJD\Postcodes\Interfaces\PostcodeServiceInterface; +use JordJD\Postcodes\Exceptions\InvalidPostcodeException; +use JordJD\Postcodes\Utils\Validator; use Exception; -use SoapClient; +use GuzzleHttp\Client; +use GuzzleHttp\ClientInterface; +use InvalidArgumentException; class PostcodeAnywhere implements PostcodeServiceInterface { private $apiKey = null; - private $findSOAPClient = null; - private $retrieveByIDSOAPClient = null; + private $client = null; - public function __construct($apiKey) + public function __construct($apiKey, ClientInterface $client = null) { if (!$apiKey) { throw new Exception('No Postcode Anywhere API key specified.'); @@ -20,8 +23,7 @@ public function __construct($apiKey) $this->apiKey = $apiKey; - $this->findSOAPClient = new SoapClient('https://services.postcodeanywhere.co.uk/PostcodeAnywhere/Interactive/Find/v1.10/wsdlnew.ws'); - $this->retrieveByIDSOAPClient = new \SoapClient('https://services.postcodeanywhere.co.uk/PostcodeAnywhere/Interactive/RetrieveById/v1.30/wsdlnew.ws'); + $this->client = $client ?: new Client(['timeout' => 3.0]); } public function getAddressesByPostcode($postcode) @@ -39,39 +41,121 @@ public function getAddressesByPostcode($postcode) public function getFindResponseAddressesByPostcode($postcode) { - $findResponse = $this->findSOAPClient->PostcodeAnywhere_Interactive_Find_v1_10( - [ - 'Key' => $this->apiKey, - 'SearchTerm' => $postcode, - ] - ); + if (!Validator::validatePostcode($postcode)) { + throw new InvalidPostcodeException('Post code provided is not valid'); + } + + $postcode = Validator::normalizePostcode($postcode); + $pendingContainers = [null]; + $seenContainers = []; + $addresses = []; - $findResponseAddresses = $findResponse->PostcodeAnywhere_Interactive_Find_v1_10_Result->PostcodeAnywhere_Interactive_Find_v1_10_Results; + while ($pendingContainers) { + $container = array_pop($pendingContainers); + $items = $this->find($postcode, $container); + + foreach ($items as $item) { + if (isset($item->Type) && strtolower($item->Type) === 'address') { + $addresses[] = $item; + continue; + } + + if (isset($item->Id) && !isset($seenContainers[$item->Id])) { + $seenContainers[$item->Id] = true; + $pendingContainers[] = $item->Id; + + if (count($seenContainers) > 100) { + throw new Exception('Loqate returned too many nested address containers.'); + } + } + } + } - return $findResponseAddresses; + return $addresses; } public function getAddressById($id) { - $retrieveByIDResponse = $this->retrieveByIDSOAPClient->PostcodeAnywhere_Interactive_RetrieveById_v1_30( - [ - 'Key' => $this->apiKey, - 'Id' => $id, - ] - ); + if (!is_string($id) || trim($id) === '') { + throw new InvalidArgumentException('A Loqate address ID must be specified.'); + } - $retrieveAddress = $retrieveByIDResponse->PostcodeAnywhere_Interactive_RetrieveById_v1_30_Result->PostcodeAnywhere_Interactive_RetrieveById_v1_30_Results; + $response = $this->client->request( + 'GET', + 'https://api.addressy.com/Capture/Interactive/Retrieve/v1.30/json6.ws', + [ + 'query' => ['Key' => $this->apiKey, 'Id' => $id], + 'http_errors' => false, + ] + ); + $items = $this->parseItemsResponse($response, 'Loqate Retrieve'); + + if (!$items) { + throw new Exception('Loqate Retrieve response does not contain an address.'); + } + + $retrieveAddress = $items[0]; $address = new Address(); - $address->companyName = $retrieveAddress->Company; - $address->line1 = $retrieveAddress->Line1; - $address->line2 = $retrieveAddress->Line2; - $address->line3 = $retrieveAddress->Line3; - $address->townCity = $retrieveAddress->PostTown; - $address->county = $retrieveAddress->County; - $address->postcode = $retrieveAddress->Postcode; - $address->country = $retrieveAddress->CountryName; + $address->companyName = isset($retrieveAddress->Company) ? $retrieveAddress->Company : ''; + $address->line1 = isset($retrieveAddress->Line1) ? $retrieveAddress->Line1 : ''; + $address->line2 = isset($retrieveAddress->Line2) ? $retrieveAddress->Line2 : ''; + $address->line3 = isset($retrieveAddress->Line3) ? $retrieveAddress->Line3 : ''; + $address->townCity = isset($retrieveAddress->City) ? $retrieveAddress->City : ''; + $address->county = isset($retrieveAddress->ProvinceName) ? $retrieveAddress->ProvinceName : ''; + $address->postcode = isset($retrieveAddress->PostalCode) ? $retrieveAddress->PostalCode : ''; + $address->country = isset($retrieveAddress->CountryName) ? $retrieveAddress->CountryName : 'United Kingdom'; + $address->longitude = isset($retrieveAddress->Longitude) ? $retrieveAddress->Longitude : null; + $address->latitude = isset($retrieveAddress->Latitude) ? $retrieveAddress->Latitude : null; return $address; } + + private function find($postcode, $container) + { + $query = [ + 'Key' => $this->apiKey, + 'Text' => $postcode, + 'Countries' => 'GBR', + 'Limit' => 100, + ]; + + if ($container !== null) { + $query['Container'] = $container; + } + + $response = $this->client->request( + 'GET', + 'https://api.addressy.com/Capture/Interactive/Find/v1.20/json6.ws', + ['query' => $query, 'http_errors' => false] + ); + + return $this->parseItemsResponse($response, 'Loqate Find'); + } + + private function parseItemsResponse($response, $operation) + { + if ($response->getStatusCode() != 200) { + throw new Exception($operation.' HTTP response code was not 200. Received '.$response->getStatusCode().' ('.$response->getReasonPhrase().')'); + } + + $object = json_decode((string) $response->getBody()); + + if (!is_object($object)) { + throw new Exception($operation.' response JSON could not be decoded.'); + } + + if (!isset($object->Items) || !is_array($object->Items)) { + throw new Exception($operation.' response does not contain an Items array.'); + } + + foreach ($object->Items as $item) { + if (isset($item->Error)) { + $message = isset($item->Description) ? $item->Description : $item->Error; + throw new Exception($operation.' failed: '.$message); + } + } + + return $object->Items; + } } diff --git a/src/Objects/PostcodesIo.php b/src/Objects/PostcodesIo.php index d65ea65..1808a6b 100644 --- a/src/Objects/PostcodesIo.php +++ b/src/Objects/PostcodesIo.php @@ -3,42 +3,50 @@ namespace JordJD\Postcodes\Objects; use JordJD\Postcodes\Interfaces\PostcodeServiceInterface; +use JordJD\Postcodes\Exceptions\InvalidPostcodeException; +use JordJD\Postcodes\Utils\Validator; use Exception; use GuzzleHttp\Client; -use GuzzleHttp\Psr7\Response; +use GuzzleHttp\ClientInterface; use InvalidArgumentException; +use Psr\Http\Message\ResponseInterface; class PostcodesIo implements PostcodeServiceInterface { private $client; - public function __construct($apiKey = null) + public function __construct($apiKey = null, ClientInterface $client = null) { if ($apiKey) { throw new InvalidArgumentException('Postcodes.io does not require an API key.'); } - $this->client = new Client(['base_uri' => 'https://api.postcodes.io/', 'timeout' => 3.0]); + $this->client = $client ?: new Client(['timeout' => 3.0]); } public function getAddressesByPostcode($postcode) { - $response = $this->client->request('GET', 'postcodes/'.$postcode); + if (!Validator::validatePostcode($postcode)) { + throw new InvalidPostcodeException('Post code provided is not valid'); + } + + $postcode = str_replace(' ', '', Validator::normalizePostcode($postcode)); + $response = $this->client->request('GET', 'https://api.postcodes.io/postcodes/'.rawurlencode($postcode), ['http_errors' => false]); $result = $this->parseResponse($response); return $result; } - private function parseResponse(Response $response) + private function parseResponse(ResponseInterface $response) { if ($response->getStatusCode() != 200) { throw new Exception('HTTP response code was not 200. Received HTTP reponse code: '.$response->getStatusCode().' ('.$response->getReasonPhrase().')'); } - $object = json_decode($response->getBody()); + $object = json_decode((string) $response->getBody()); - if (!$object) { + if (!is_object($object)) { throw new Exception('Response JSON could not be decoded.'); } @@ -59,12 +67,13 @@ private function parseResponse(Response $response) $addresses = []; $address = new Address(); - $address->line2 = $postcodesIoAddress->parish; - $address->townCity = $postcodesIoAddress->admin_district; - $address->county = $postcodesIoAddress->admin_county; - $address->postcode = $postcodesIoAddress->postcode; - $address->longitude = $postcodesIoAddress->longitude; - $address->latitude = $postcodesIoAddress->latitude; + $address->line2 = isset($postcodesIoAddress->parish) ? $postcodesIoAddress->parish : ''; + $address->townCity = isset($postcodesIoAddress->admin_district) ? $postcodesIoAddress->admin_district : ''; + $address->county = isset($postcodesIoAddress->admin_county) ? $postcodesIoAddress->admin_county : ''; + $address->country = isset($postcodesIoAddress->country) ? $postcodesIoAddress->country : 'United Kingdom'; + $address->postcode = isset($postcodesIoAddress->postcode) ? $postcodesIoAddress->postcode : ''; + $address->longitude = isset($postcodesIoAddress->longitude) ? $postcodesIoAddress->longitude : null; + $address->latitude = isset($postcodesIoAddress->latitude) ? $postcodesIoAddress->latitude : null; $addresses[] = $address; diff --git a/src/Utils/Tokenizer.php b/src/Utils/Tokenizer.php index 39c0ace..83aa1d6 100755 --- a/src/Utils/Tokenizer.php +++ b/src/Utils/Tokenizer.php @@ -10,14 +10,14 @@ public static function outward($postcode) { self::sanityCheck($postcode); - return strtoupper(trim(substr($postcode, 0, -3))); + return substr(Validator::normalizePostcode($postcode), 0, -4); } public static function inward($postcode) { self::sanityCheck($postcode); - return strtoupper(trim(substr($postcode, -3, 3))); + return substr(Validator::normalizePostcode($postcode), -3); } private static function sanityCheck($postcode) diff --git a/src/Utils/Validator.php b/src/Utils/Validator.php index 2324b6d..2c6ef0a 100644 --- a/src/Utils/Validator.php +++ b/src/Utils/Validator.php @@ -12,16 +12,45 @@ abstract class Validator public static function validatePostcode($postcode) { - $postcode = strtoupper($postcode); + $postcode = self::normalizePostcode($postcode); + + if ($postcode === null) { + return false; + } - if (in_array(str_replace(' ', '', $postcode), self::$invalidPostcodes)) { + $compactPostcode = str_replace(' ', '', $postcode); + + if (in_array($compactPostcode, self::$invalidPostcodes, true)) { return false; } - $regex = '#^(GIR ?0AA|[A-PR-UWYZ]([0-9]{1,2}|([A-HK-Y][0-9]([0-9ABEHMNPRV-Y])?)|[0-9][A-HJKPS-UW]) ?[0-9][ABD-HJLNP-UW-Z]{2})$#'; + $regex = '#^(GIR0AA|[A-PR-UWYZ]([0-9]{1,2}|([A-HK-Y][0-9]([0-9ABEHMNPRV-Y])?)|[0-9][A-HJKPS-UW])[0-9][ABD-HJLNP-UW-Z]{2})$#'; - $result = preg_match($regex, $postcode); + $result = preg_match($regex, $compactPostcode); return $result ? true : false; } + + /** + * Normalize a postcode to the conventional uppercase format. + * + * Invalid input is returned as null. Use validatePostcode() when the + * postcode must also be checked against the UK postcode pattern. + */ + public static function normalizePostcode($postcode) + { + if (!is_string($postcode)) { + return null; + } + + $postcode = preg_replace('/\s+/', '', trim($postcode)); + + if ($postcode === '' || strlen($postcode) < 4) { + return null; + } + + $postcode = strtoupper($postcode); + + return substr($postcode, 0, -3).' '.substr($postcode, -3); + } } diff --git a/tests/Unit/BasicUsageTest.php b/tests/Unit/BasicUsageTest.php index e645b53..9aa6fe9 100644 --- a/tests/Unit/BasicUsageTest.php +++ b/tests/Unit/BasicUsageTest.php @@ -10,7 +10,7 @@ final class BasicUsageTest extends TestCase { - public function validationProvider() + public static function validationProvider() { return [ ['ST163DP'], @@ -27,12 +27,13 @@ public function validationProvider() /** * @dataProvider validationProvider */ + #[\PHPUnit\Framework\Attributes\DataProvider('validationProvider')] public function testValidation($postcode) { $this->assertTrue(Validator::validatePostcode($postcode)); } - public function validationFailureProvider() + public static function validationFailureProvider() { return [ ['ST163DPA'], @@ -43,12 +44,16 @@ public function validationFailureProvider() ['KT18 5DN'], ['AB15 4YR'], ['B62 8RS'], + [null], + [12345], + [[]], ]; } /** * @dataProvider validationFailureProvider */ + #[\PHPUnit\Framework\Attributes\DataProvider('validationFailureProvider')] public function testValidationFailure($postcode) { $this->assertFalse(Validator::validatePostcode($postcode)); @@ -67,7 +72,7 @@ public function testGeneration() } } - public function outwardAndInwardCodesProvider() + public static function outwardAndInwardCodesProvider() { return [ [ @@ -121,6 +126,7 @@ public function outwardAndInwardCodesProvider() /** * @dataProvider outwardAndInwardCodesProvider */ + #[\PHPUnit\Framework\Attributes\DataProvider('outwardAndInwardCodesProvider')] public function testOutwardAndInwardCodes($postcode, $outward, $inward) { $this->assertEquals($outward, Tokenizer::outward($postcode)); @@ -142,4 +148,10 @@ public function testInwardCodeWithInvalidPostcode() Tokenizer::inward('ST163DPA'); } + + public function testPostcodeNormalization() + { + $this->assertSame('SW1A 2AA', Validator::normalizePostcode(" sw1a\t2aa\n")); + $this->assertNull(Validator::normalizePostcode(null)); + } } diff --git a/tests/Unit/PostcodeServicesTest.php b/tests/Unit/PostcodeServicesTest.php new file mode 100644 index 0000000..5072f7e --- /dev/null +++ b/tests/Unit/PostcodeServicesTest.php @@ -0,0 +1,118 @@ + 200, + 'result' => [ + 'postcode' => 'ST16 3DP', + 'parish' => 'Stafford', + 'admin_district' => 'Stafford', + 'admin_county' => 'Staffordshire', + 'country' => 'England', + 'longitude' => -2.12, + 'latitude' => 52.8, + ], + ])), + ]); + + $service = new PostcodesIo(null, new Client(['handler' => $mock])); + $addresses = $service->getAddressesByPostcode(' st16 3dp '); + + $this->assertCount(1, $addresses); + $this->assertSame('Stafford', $addresses[0]->townCity); + $this->assertSame('England', $addresses[0]->country); + $this->assertSame('https://api.postcodes.io/postcodes/ST163DP', (string) $mock->getLastRequest()->getUri()); + } + + public function testPostcodesIoRejectsInvalidInputBeforeMakingRequest() + { + $this->expectException(InvalidPostcodeException::class); + + $service = new PostcodesIo(null, new Client(['handler' => new MockHandler()])); + $service->getAddressesByPostcode(null); + } + + public function testIdealPostcodesLookupMapsCurrentResponseAndAuthentication() + { + $mock = new MockHandler([ + new Response(200, [], json_encode([ + 'code' => 2000, + 'result' => [[ + 'organisation_name' => 'Example Ltd', + 'line_1' => '1 Example Street', + 'line_2' => '', + 'line_3' => '', + 'post_town' => 'London', + 'county' => 'Greater London', + 'country' => 'England', + 'postcode' => 'SW1A 2AA', + 'longitude' => -0.1276, + 'latitude' => 51.5034, + ]], + ])), + ]); + + $service = new IdealPostcodes('ak_test', new Client(['handler' => $mock])); + $addresses = $service->getAddressesByPostcode('SW1A2AA'); + + $this->assertCount(1, $addresses); + $this->assertSame('Example Ltd', $addresses[0]->companyName); + $this->assertSame('api_key="ak_test"', $mock->getLastRequest()->getHeaderLine('Authorization')); + } + + public function testLoqateLookupResolvesContainersUsingCurrentJsonApi() + { + $mock = new MockHandler([ + new Response(200, [], json_encode([ + 'Items' => [[ + 'Id' => 'GB|RM|C|ST163DP', + 'Type' => 'Postcode', + 'Text' => 'ST16 3DP', + ]], + ])), + new Response(200, [], json_encode([ + 'Items' => [[ + 'Id' => 'GB|RM|A|123', + 'Type' => 'Address', + 'Text' => '1 Example Street', + ]], + ])), + new Response(200, [], json_encode([ + 'Items' => [[ + 'Company' => 'Example Ltd', + 'Line1' => '1 Example Street', + 'Line2' => '', + 'Line3' => '', + 'City' => 'Stafford', + 'ProvinceName' => 'Staffordshire', + 'PostalCode' => 'ST16 3DP', + 'CountryName' => 'United Kingdom', + ]], + ])), + ]); + + $service = new PostcodeAnywhere('AA11-AA11-AA11-AA11', new Client(['handler' => $mock])); + $addresses = $service->getAddressesByPostcode('ST16 3DP'); + + $this->assertCount(1, $addresses); + $this->assertSame('1 Example Street', $addresses[0]->line1); + $this->assertSame('Stafford', $addresses[0]->townCity); + $this->assertSame('ST16 3DP', $addresses[0]->postcode); + $this->assertStringContainsString('/Capture/Interactive/Retrieve/v1.30/json6.ws', (string) $mock->getLastRequest()->getUri()); + } +} diff --git a/tests/Unit/PostcodesIoTest.php b/tests/Unit/PostcodesIoTest.php deleted file mode 100644 index c1fcb63..0000000 --- a/tests/Unit/PostcodesIoTest.php +++ /dev/null @@ -1,38 +0,0 @@ -getAddressesByPostcode($postcode); - } catch (\Throwable $e) { - $this->markTestSkipped('Postcodes.io lookup unavailable: ' . $e->getMessage()); - return; - } - - $address = $addresses[0]; - - $this->assertNotEmpty($address->townCity); - $this->assertNotEmpty($address->postcode); - $this->assertIsNumeric($address->longitude); - $this->assertIsNumeric($address->latitude); - } -}