diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 14:58:55 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 14:58:55 -0300 |
| commit | 64764fb9fac0227f8e0443e7c7519d73aec275b9 (patch) | |
| tree | cb4fc56eeb6ecce53854f9c530609cf01ab3349f /tests | |
| download | php-0.1.0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/BatchTest.php | 139 | ||||
| -rw-r--r-- | tests/ConfigurationTest.php | 60 | ||||
| -rw-r--r-- | tests/HealthTest.php | 41 | ||||
| -rw-r--r-- | tests/LookupTest.php | 154 | ||||
| -rw-r--r-- | tests/NormalizationTest.php | 44 | ||||
| -rw-r--r-- | tests/RecordingTransport.php | 39 | ||||
| -rw-r--r-- | tests/RetryTest.php | 127 | ||||
| -rw-r--r-- | tests/ServerTestCase.php | 106 | ||||
| -rw-r--r-- | tests/StreamTransport.php | 52 | ||||
| -rw-r--r-- | tests/TestServer.php | 148 | ||||
| -rw-r--r-- | tests/TransportTest.php | 141 | ||||
| -rw-r--r-- | tests/server/router.php | 53 |
12 files changed, 1104 insertions, 0 deletions
diff --git a/tests/BatchTest.php b/tests/BatchTest.php new file mode 100644 index 0000000..95b25ff --- /dev/null +++ b/tests/BatchTest.php @@ -0,0 +1,139 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +final class BatchTest extends ServerTestCase +{ + public function testPreservesOrderAndMarksMissing(): void + { + $this->server->respond('/csv?ceps=01001000%2C99999999%2C20040020', [self::csv([ + '01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107', + '99999999,,,,,,,,,,,,', + '20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001', + ])]); + + $result = $this->client->lookupMany(['01001000', '99999999', '20040020']); + + self::assertCount(3, $result); + self::assertNotNull($result[0]); + self::assertSame('São Paulo', $result[0]->localidade); + self::assertNull($result[1]); + self::assertNotNull($result[2]); + self::assertSame('RJ', $result[2]->uf); + self::assertSame('Avenida Rio Branco', $result[2]->logradouro); + } + + public function testMissingEntryDoesNotTruncateTheArray(): void + { + $this->server->respond('/csv?ceps=99999999%2C01001000', [self::csv([ + '99999999,,,,,,,,,,,,', + '01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1', + ])]); + + $result = $this->client->lookupMany(['99999999', '01001000']); + + self::assertCount(2, $result); + self::assertNull($result[0]); + self::assertNotNull($result[1]); + self::assertSame([0, 1], array_keys($result)); + } + + public function testBatchCarriesNoCoordinate(): void + { + $this->server->respond('/csv?ceps=01001000', [self::csv([ + '01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1', + ])]); + + $first = $this->client->lookupMany(['01001000'])[0]; + self::assertNotNull($first); + self::assertNull($first->geo); + } + + public function testFieldWithCommaIsParsed(): void + { + $this->server->respond('/csv?ceps=01001000', [self::csv([ + '01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1', + ])]); + + $first = $this->client->lookupMany(['01001000'])[0]; + self::assertNotNull($first); + self::assertSame('Rua Um, Dois', $first->logradouro); + } + + public function testEscapedQuoteIsParsed(): void + { + $this->server->respond('/csv?ceps=01001000', [self::csv([ + '01001000,"Rua ""Dom"" Pedro",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1', + ])]); + + $first = $this->client->lookupMany(['01001000'])[0]; + self::assertNotNull($first); + self::assertSame('Rua "Dom" Pedro', $first->logradouro); + } + + public function testTrailingBackslashDoesNotSwallowTheQuote(): void + { + $this->server->respond('/csv?ceps=01001000', [self::csv([ + '01001000,"Rua Barra\\",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1', + ])]); + + $first = $this->client->lookupMany(['01001000'])[0]; + self::assertNotNull($first); + self::assertSame('Rua Barra\\', $first->logradouro); + self::assertSame('São Paulo', $first->localidade); + } + + public function testEmptyListMakesNoRequest(): void + { + self::assertSame([], $this->client->lookupMany([])); + self::assertSame([], $this->server->calls()); + } + + public function testChunksAboveTheApiLimit(): void + { + $ceps = []; + for ($i = 0; $i < 501; $i++) { + $ceps[] = sprintf('%08d', $i); + } + $this->server->respond('*', [['kind' => 'csv_echo']]); + + $result = $this->client->lookupMany($ceps); + + self::assertCount(501, $result); + self::assertSame(2, count($this->server->calls())); + self::assertNotNull($result[0]); + self::assertSame('00000000', $result[0]->cep); + self::assertNotNull($result[500]); + self::assertSame('00000500', $result[500]->cep); + } + + public function testTruncatedResponseDoesNotShift(): void + { + $this->server->respond('/csv?ceps=01001000%2C20040020', [self::csv([ + '01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1', + ])]); + + $result = $this->client->lookupMany(['01001000', '20040020']); + + self::assertCount(2, $result); + self::assertNotNull($result[0]); + self::assertSame('01001000', $result[0]->cep); + self::assertNull($result[1]); + } + + public function testMalformedCepIsNullNotAnException(): void + { + $this->server->respond('/csv?ceps=01001000%2Cnot-a-cep', [self::csv([ + '01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1', + 'not-a-cep,,,,,,,,,,,,', + ])]); + + $result = $this->client->lookupMany(['01001000', 'not-a-cep']); + + self::assertCount(2, $result); + self::assertNotNull($result[0]); + self::assertNull($result[1]); + } +} diff --git a/tests/ConfigurationTest.php b/tests/ConfigurationTest.php new file mode 100644 index 0000000..158d911 --- /dev/null +++ b/tests/ConfigurationTest.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +use Cepis\Client; +use PHPUnit\Framework\TestCase; + +final class ConfigurationTest extends TestCase +{ + public function testTrailingSlashIsNotDuplicated(): void + { + self::assertSame('http://x', (new Client(baseUrl: 'http://x/'))->baseUrl); + self::assertSame('http://x', (new Client(baseUrl: 'http://x///'))->baseUrl); + } + + public function testDefaults(): void + { + $client = new Client(); + + self::assertSame(Client::DEFAULT_URL, $client->baseUrl); + self::assertSame(3, $client->maxAttempts); + self::assertSame(0.5, $client->backoffBase); + self::assertSame(8.0, $client->backoffMax); + self::assertNull($client->totalTimeout); + } + + public function testUserAgentIdentifiesTheLibrary(): void + { + self::assertStringContainsString( + 'cepis-php/' . Client::VERSION, + (new Client())->userAgent, + ); + self::assertSame('myapp/1.0', (new Client(userAgent: 'myapp/1.0'))->userAgent); + } + + public function testInvalidParameters(): void + { + foreach ([['maxAttempts' => 0], ['timeout' => 0.0], ['backoffBase' => -1.0]] as $bad) { + try { + new Client(...$bad); + self::fail('expected InvalidArgumentException for ' . json_encode($bad)); + } catch (\InvalidArgumentException) { + self::assertTrue(true); + } + } + } + + public function testDefaultClientIsShared(): void + { + self::assertSame(Client::default(), Client::default()); + self::assertSame(Client::DEFAULT_URL, Client::default()->baseUrl); + } + + public function testMaxBatchMatchesTheApiLimit(): void + { + self::assertSame(500, Client::MAX_BATCH); + } +} diff --git a/tests/HealthTest.php b/tests/HealthTest.php new file mode 100644 index 0000000..08c047b --- /dev/null +++ b/tests/HealthTest.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +final class HealthTest extends ServerTestCase +{ + public function testReadsHealth(): void + { + $this->server->respond('/health', [self::json([ + 'status' => 'ok', + 'dne' => '26071', + 'ceps' => 1596168, + 'geo' => 894489, + 'base_gerada_em' => '2026-07-24T03:48:28Z', + ])]); + + $health = $this->client->health(); + + self::assertTrue($health->isOk()); + self::assertSame('26071', $health->dne); + self::assertSame(1596168, $health->ceps); + self::assertSame(894489, $health->geo); + self::assertSame('2026-07-24T03:48:28Z', $health->base_gerada_em); + } + + public function testDegradedDoesNotThrow(): void + { + $this->server->respond('/health', [self::json( + ['status' => 'degradado', 'dne' => '', 'ceps' => 0, 'geo' => 0], + 503, + )]); + + $health = $this->client->health(); + + self::assertFalse($health->isOk()); + self::assertSame('degradado', $health->status); + self::assertSame(1, $this->server->countCalls('/health')); + } +} diff --git a/tests/LookupTest.php b/tests/LookupTest.php new file mode 100644 index 0000000..9483948 --- /dev/null +++ b/tests/LookupTest.php @@ -0,0 +1,154 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +use Cepis\CepNotFoundException; +use Cepis\InvalidCepException; +use Cepis\InvalidResponseException; + +final class LookupTest extends ServerTestCase +{ + public function testSuccessfulLookup(): void + { + $this->server->respond('/01001000', [self::json(self::SE_ADDRESS)]); + $address = $this->client->lookup('01001-000'); + + self::assertSame('Praça da Sé', $address->logradouro); + self::assertSame('São Paulo', $address->localidade); + self::assertSame('SP', $address->uf); + self::assertSame('01001000', $address->cep); + self::assertSame('01001-000', $address->formattedCep()); + self::assertNull($address->geo); + } + + public function testReadsTheCoordinateWhenPresent(): void + { + $body = self::SE_ADDRESS + []; + $body['geo'] = ['lat' => -23.567202, 'lon' => -46.649659, 'raio_m' => 1084, 'precisao' => 'logradouro']; + $this->server->respond('/01310100', [self::json($body)]); + $address = $this->client->lookup('01310100'); + + self::assertNotNull($address->geo); + self::assertEqualsWithDelta(-23.567202, $address->geo->lat, 1e-9); + self::assertEqualsWithDelta(-46.649659, $address->geo->lon, 1e-9); + self::assertSame(1084, $address->geo->raio_m); + self::assertSame('logradouro', $address->geo->precisao); + self::assertTrue($address->geo->isPrecise()); + } + + public function testMunicipalityCoordinateIsNotPrecise(): void + { + $body = self::SE_ADDRESS; + $body['geo'] = ['lat' => -23.5, 'lon' => -46.6, 'raio_m' => 22000, 'precisao' => 'localidade']; + $this->server->respond('/01001000', [self::json($body)]); + + $geo = $this->client->lookup('01001000')->geo; + self::assertNotNull($geo); + self::assertFalse($geo->isPrecise()); + } + + public function testMalformedGeoDoesNotLoseTheAddress(): void + { + $body = self::SE_ADDRESS; + $body['geo'] = ['lat' => 'not a number']; + $this->server->respond('/01001000', [self::json($body)]); + $address = $this->client->lookup('01001000'); + + self::assertNull($address->geo); + self::assertSame('São Paulo', $address->localidade); + } + + public function testNewApiFieldDoesNotBreak(): void + { + $body = self::SE_ADDRESS; + $body['campo_do_futuro'] = 'value'; + $this->server->respond('/01001000', [self::json($body)]); + $address = $this->client->lookup('01001000'); + + self::assertSame('value', $address->raw['campo_do_futuro']); + self::assertSame('value', $address->field('campo_do_futuro')); + self::assertNull($address->field('nao_existe')); + self::assertSame('São Paulo', $address->localidade); + } + + public function testMissingFieldsBecomeEmptyStringsNotNull(): void + { + $this->server->respond('/01001000', [self::json(['cep' => '01001000'])]); + $address = $this->client->lookup('01001000'); + + self::assertSame('', $address->logradouro); + self::assertSame('', $address->uf); + } + + public function testNotFound(): void + { + $this->server->respond('/99999999', [self::json( + ['erro' => 'cep_nao_encontrado', 'mensagem' => 'CEP não existe na base do DNE'], + 404, + )]); + + try { + $this->client->lookup('99999999'); + self::fail('expected CepNotFoundException'); + } catch (CepNotFoundException $exception) { + self::assertStringContainsString('99999999', $exception->getMessage()); + } + } + + public function testNotFoundIsNotRetried(): void + { + $this->server->respond('/99999999', [self::json(['erro' => 'cep_nao_encontrado'], 404)]); + + $this->expectException(CepNotFoundException::class); + + try { + $this->client->lookup('99999999'); + } finally { + self::assertSame(1, $this->server->countCalls('/99999999')); + } + } + + public function testInvalidCepNeverLeavesTheMachine(): void + { + try { + $this->client->lookup('123'); + self::fail('expected InvalidCepException'); + } catch (InvalidCepException) { + self::assertSame([], $this->server->calls()); + } + } + + public function testApi400BecomesInvalidCep(): void + { + $this->server->respond('/00000000', [self::json( + ['erro' => 'cep_invalido', 'mensagem' => 'rejected by the API'], + 400, + )]); + + try { + $this->client->lookup('00000000'); + self::fail('expected InvalidCepException'); + } catch (InvalidCepException $exception) { + self::assertSame('rejected by the API', $exception->getMessage()); + self::assertSame(1, $this->server->countCalls('/00000000')); + } + } + + public function testResponseThatIsNotJson(): void + { + $this->server->respond('/01001000', [self::raw(200, '<html>captive portal</html>')]); + + $this->expectException(InvalidResponseException::class); + $this->client->lookup('01001000'); + } + + public function testJsonArrayIsNotAnObject(): void + { + $this->server->respond('/01001000', [self::raw(200, '[1, 2, 3]')]); + + $this->expectException(InvalidResponseException::class); + $this->client->lookup('01001000'); + } +} diff --git a/tests/NormalizationTest.php b/tests/NormalizationTest.php new file mode 100644 index 0000000..36de024 --- /dev/null +++ b/tests/NormalizationTest.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +use Cepis\CepisException; +use Cepis\Client; +use Cepis\InvalidCepException; +use PHPUnit\Framework\TestCase; + +final class NormalizationTest extends TestCase +{ + public function testAcceptsMaskedAndUnmasked(): void + { + foreach (['01001000', '01001-000', ' 01001-000 ', '01001.000'] as $value) { + self::assertSame('01001000', Client::normalizeCep($value)); + } + } + + public function testRejectsBadFormat(): void + { + foreach (['', '123', '0100100a', '010010000', 'abcdefgh', '01001-0000'] as $value) { + try { + Client::normalizeCep($value); + self::fail(sprintf('expected "%s" to be rejected', $value)); + } catch (InvalidCepException $exception) { + self::assertStringContainsString('8 digits', $exception->getMessage()); + } + } + } + + public function testOneCatchHandlesEverything(): void + { + $this->expectException(CepisException::class); + Client::normalizeCep('123'); + } + + public function testInvalidCepIsAlsoAnInvalidArgumentException(): void + { + $this->expectException(\InvalidArgumentException::class); + Client::normalizeCep('123'); + } +} diff --git a/tests/RecordingTransport.php b/tests/RecordingTransport.php new file mode 100644 index 0000000..da93fb3 --- /dev/null +++ b/tests/RecordingTransport.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +use Cepis\NetworkException; +use Cepis\Response; +use Cepis\Transport; + +/** + * Records what the client asked for and replays a canned list of responses. + * + * Used only to test the seam itself — what the client passes an implementer, + * and how it reacts to what one returns. Every test of the client's own + * behaviour goes over a real socket to {@see TestServer}. + */ +final class RecordingTransport implements Transport +{ + /** @var list<array{url: string, headers: array<string, string>, timeout: float}> */ + public array $calls = []; + + /** @param list<Response> $responses */ + public function __construct(private array $responses = []) + { + } + + public function get(string $url, array $headers, float $timeout): Response + { + $this->calls[] = ['url' => $url, 'headers' => $headers, 'timeout' => $timeout]; + + $response = array_shift($this->responses); + if ($response === null) { + throw new NetworkException('no response left for ' . $url); + } + + return $response; + } +} diff --git a/tests/RetryTest.php b/tests/RetryTest.php new file mode 100644 index 0000000..1b922b6 --- /dev/null +++ b/tests/RetryTest.php @@ -0,0 +1,127 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +use Cepis\Client; +use Cepis\NetworkException; +use Cepis\RateLimitedException; +use Cepis\ServerErrorException; + +final class RetryTest extends ServerTestCase +{ + public function testRetriesOn500ThenSucceeds(): void + { + $this->server->respond('/01001000', [ + self::json(['erro' => 'interno'], 500), + self::json(self::SE_ADDRESS), + ]); + + self::assertSame('São Paulo', $this->client->lookup('01001000')->localidade); + self::assertSame(2, $this->server->countCalls('/01001000')); + } + + public function testExhaustsAttemptsThenThrows(): void + { + $this->server->respond('/01001000', [self::json(['erro' => 'interno'], 503)]); + + try { + $this->client->lookup('01001000'); + self::fail('expected ServerErrorException'); + } catch (ServerErrorException $exception) { + self::assertSame(503, $exception->status); + self::assertSame(3, $this->server->countCalls('/01001000')); + } + } + + public function testMaxAttemptsOneDisablesRetry(): void + { + $client = new Client(baseUrl: $this->server->url, timeout: 5.0, maxAttempts: 1); + $this->server->respond('/01001000', [self::raw(500, '{}')]); + + try { + $client->lookup('01001000'); + self::fail('expected ServerErrorException'); + } catch (ServerErrorException) { + self::assertSame(1, $this->server->countCalls('/01001000')); + } + } + + public function test429BecomesRateLimitedWithRetryAfter(): void + { + $this->server->respond('/01001000', [self::raw(429, '{}', ['Retry-After' => '7'])]); + + try { + $this->client->lookup('01001000'); + self::fail('expected RateLimitedException'); + } catch (RateLimitedException $exception) { + self::assertNotNull($exception->retryAfter); + self::assertEqualsWithDelta(7.0, $exception->retryAfter, 1e-9); + } + } + + public function testRetryAfterAcceptsAnHttpDate(): void + { + $date = gmdate('D, d M Y H:i:s \G\M\T', time() + 4); + $this->server->respond('/01001000', [self::raw(429, '{}', ['Retry-After' => $date])]); + + try { + $this->client->lookup('01001000'); + self::fail('expected RateLimitedException'); + } catch (RateLimitedException $exception) { + self::assertNotNull($exception->retryAfter); + self::assertGreaterThan(0.0, $exception->retryAfter); + self::assertLessThanOrEqual(5.0, $exception->retryAfter); + } + } + + public function testRetryAfterHonoursTheCap(): void + { + $this->server->respond('/01001000', [self::raw(429, '{}', ['Retry-After' => '3600'])]); + $started = self::monotonic(); + + try { + $this->client->lookup('01001000'); + self::fail('expected RateLimitedException'); + } catch (RateLimitedException) { + self::assertLessThan(5.0, self::monotonic() - $started); + } + } + + public function testNetworkErrorWhenNothingListens(): void + { + $client = new Client( + baseUrl: 'http://127.0.0.1:1', + timeout: 1.0, + maxAttempts: 2, + backoffBase: 0.001, + backoffMax: 0.01, + ); + + $this->expectException(NetworkException::class); + $client->lookup('01001000'); + } + + public function testTotalTimeoutBoundsTheWholeCall(): void + { + $client = new Client( + baseUrl: $this->server->url, + timeout: 5.0, + maxAttempts: 50, + backoffBase: 0.05, + backoffMax: 0.05, + totalTimeout: 0.3, + ); + $this->server->respond('/01001000', [self::raw(500, '{}')]); + $started = self::monotonic(); + + try { + $client->lookup('01001000'); + self::fail('expected ServerErrorException'); + } catch (ServerErrorException) { + self::assertLessThan(5.0, self::monotonic() - $started); + self::assertLessThan(50, $this->server->countCalls('/01001000')); + } + } +} diff --git a/tests/ServerTestCase.php b/tests/ServerTestCase.php new file mode 100644 index 0000000..d000778 --- /dev/null +++ b/tests/ServerTestCase.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +use Cepis\Client; +use PHPUnit\Framework\TestCase; + +/** + * A fake server and a client whose backoff is near zero. + * + * The backoff is tiny because what is under test is the retry logic, not + * `usleep`'s ability to sleep. + */ +abstract class ServerTestCase extends TestCase +{ + /** + * A CEP as the API returns it, used as the fixture across the suite. The + * field names stay Portuguese because they mirror the API's JSON. + */ + protected const SE_ADDRESS = [ + 'cep' => '01001000', + 'logradouro' => 'Praça da Sé', + 'complemento' => 'lado ímpar', + 'unidade' => '', + 'bairro' => 'Sé', + 'localidade' => 'São Paulo', + 'uf' => 'SP', + 'estado' => 'São Paulo', + 'regiao' => 'Sudeste', + 'ibge' => '3550308', + 'gia' => '1004', + 'ddd' => '11', + 'siafi' => '7107', + ]; + + protected const CSV_HEADER = 'cep,logradouro,complemento,unidade,bairro,localidade,uf,' + . 'estado,regiao,ibge,gia,ddd,siafi'; + + protected TestServer $server; + + protected Client $client; + + protected function setUp(): void + { + $this->server = new TestServer(); + $this->client = new Client( + baseUrl: $this->server->url, + timeout: 5.0, + backoffBase: 0.001, + backoffMax: 0.01, + ); + } + + protected function tearDown(): void + { + $this->server->stop(); + } + + /** + * @param array<string, mixed> $body + * @param array<string, string> $headers + * + * @return array<string, mixed> + */ + protected static function json(array $body, int $status = 200, array $headers = []): array + { + return [ + 'status' => $status, + 'body' => json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), + 'headers' => $headers, + 'content_type' => 'application/json; charset=utf-8', + ]; + } + + /** + * @param array<string, string> $headers + * + * @return array<string, mixed> + */ + protected static function raw(int $status, string $body, array $headers = []): array + { + return ['status' => $status, 'body' => $body, 'headers' => $headers]; + } + + /** + * @param list<string> $rows + * + * @return array<string, mixed> + */ + protected static function csv(array $rows): array + { + return [ + 'status' => 200, + 'body' => self::CSV_HEADER . "\n" . implode("\n", $rows) . "\n", + 'headers' => [], + 'content_type' => 'text/csv; charset=utf-8', + ]; + } + + protected static function monotonic(): float + { + return hrtime(true) / 1_000_000_000; + } +} diff --git a/tests/StreamTransport.php b/tests/StreamTransport.php new file mode 100644 index 0000000..174e516 --- /dev/null +++ b/tests/StreamTransport.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +use Cepis\NetworkException; +use Cepis\Response; +use Cepis\Transport; + +/** + * A {@see Transport} over PHP streams rather than curl — roughly the shape a + * WordPress plugin's `wp_remote_get` implementation takes, and proof that the + * seam is implementable against a different HTTP stack in a few lines. + */ +final class StreamTransport implements Transport +{ + public function get(string $url, array $headers, float $timeout): Response + { + $lines = ''; + foreach ($headers as $name => $value) { + $lines .= $name . ': ' . $value . "\r\n"; + } + + $context = stream_context_create(['http' => [ + 'method' => 'GET', + 'header' => $lines, + 'timeout' => $timeout, + 'ignore_errors' => true, + ]]); + + $body = @file_get_contents($url, false, $context); + if ($body === false) { + throw new NetworkException('could not reach ' . $url); + } + + $status = 0; + $received = []; + foreach ($http_response_header ?? [] as $line) { + if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match) === 1) { + $status = (int) $match[1]; + continue; + } + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $received[trim($parts[0])][] = trim($parts[1]); + } + } + + return new Response($status, $received, $body); + } +} diff --git a/tests/TestServer.php b/tests/TestServer.php new file mode 100644 index 0000000..b0b384f --- /dev/null +++ b/tests/TestServer.php @@ -0,0 +1,148 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +/** + * A throwaway HTTP server on a random loopback port, run with PHP's built-in + * server. + * + * The suite points a real {@see \Cepis\Client} at this over a real socket. No + * test stubs the transport to exercise the client, so the code path under test + * is the one that runs in production, request construction and error body + * reading included. It doubles as the test that `baseUrl` works: if it did + * not, none of the suite would run. + * + * The server process and the test process share state through two files, since + * they are separate processes: `routes.json`, which the test writes, and + * `calls.log`, which the server appends to. + */ +final class TestServer +{ + public readonly string $url; + + /** @var resource */ + private $process; + + private string $stateDir; + + public function __construct() + { + $this->stateDir = sys_get_temp_dir() . '/cepis-test-' . bin2hex(random_bytes(8)); + if (!mkdir($this->stateDir, 0o700, true) && !is_dir($this->stateDir)) { + throw new \RuntimeException('could not create ' . $this->stateDir); + } + file_put_contents($this->stateDir . '/routes.json', '{}'); + file_put_contents($this->stateDir . '/calls.log', ''); + + $port = self::freePort(); + $this->url = 'http://127.0.0.1:' . $port; + + $process = proc_open( + [PHP_BINARY, '-S', '127.0.0.1:' . $port, __DIR__ . '/server/router.php'], + [ + 0 => ['pipe', 'r'], + 1 => ['file', $this->stateDir . '/server.log', 'a'], + 2 => ['file', $this->stateDir . '/server.log', 'a'], + ], + $pipes, + __DIR__ . '/server', + ['CEPIS_TEST_STATE' => $this->stateDir, 'PATH' => getenv('PATH') ?: '/usr/bin'], + ); + if (!is_resource($process)) { + throw new \RuntimeException('could not start the built-in server'); + } + $this->process = $process; + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + $this->waitUntilListening($port); + } + + /** + * Register the responses for a request URI. + * + * Each request consumes the next entry, which is how retrying is tested; + * the last entry repeats once the list runs out. A route registered under + * `"*"` answers any URI that has none of its own. + * + * @param list<array<string, mixed>> $responses + */ + public function respond(string $uri, array $responses): void + { + $routes = json_decode((string) file_get_contents($this->stateDir . '/routes.json'), true); + $routes = is_array($routes) ? $routes : []; + $routes[$uri] = $responses; + file_put_contents( + $this->stateDir . '/routes.json', + json_encode($routes, JSON_THROW_ON_ERROR), + ); + } + + /** + * Every request URI the server has seen, in order. + * + * @return list<string> + */ + public function calls(): array + { + $raw = (string) file_get_contents($this->stateDir . '/calls.log'); + + return array_values(array_filter(explode("\n", $raw), 'strlen')); + } + + /** + * How many requests so far began with `$prefix`. + */ + public function countCalls(string $prefix): int + { + return count(array_filter( + $this->calls(), + static fn (string $uri): bool => str_starts_with($uri, $prefix), + )); + } + + public function stop(): void + { + if (is_resource($this->process)) { + proc_terminate($this->process); + proc_close($this->process); + } + foreach (['routes.json', 'calls.log', 'server.log'] as $name) { + @unlink($this->stateDir . '/' . $name); + } + @rmdir($this->stateDir); + } + + private function waitUntilListening(int $port): void + { + $deadline = microtime(true) + 15.0; + while (microtime(true) < $deadline) { + $socket = @fsockopen('127.0.0.1', $port, $code, $message, 0.2); + if (is_resource($socket)) { + fclose($socket); + + return; + } + usleep(20_000); + } + + throw new \RuntimeException('the built-in server never started listening on ' . $port); + } + + private static function freePort(): int + { + $socket = stream_socket_server('tcp://127.0.0.1:0', $code, $message); + if ($socket === false) { + throw new \RuntimeException('could not bind a loopback socket: ' . $message); + } + $name = (string) stream_socket_get_name($socket, false); + fclose($socket); + + return (int) substr($name, (int) strrpos($name, ':') + 1); + } +} diff --git a/tests/TransportTest.php b/tests/TransportTest.php new file mode 100644 index 0000000..da467a1 --- /dev/null +++ b/tests/TransportTest.php @@ -0,0 +1,141 @@ +<?php + +declare(strict_types=1); + +namespace Cepis\Tests; + +use Cepis\CepNotFoundException; +use Cepis\Client; +use Cepis\CurlTransport; +use Cepis\NetworkException; +use Cepis\RateLimitedException; +use Cepis\Response; +use Cepis\ServerErrorException; + +/** + * The transport seam is the extension point a WordPress plugin uses to route + * requests through `wp_remote_get`, so it gets tested the same way the rest + * does: a real implementation against the real test server. + */ +final class TransportTest extends ServerTestCase +{ + public function testDefaultTransportIsCurl(): void + { + self::assertInstanceOf(CurlTransport::class, (new Client())->transport); + } + + public function testAnAlternativeTransportIsUsedEndToEnd(): void + { + $client = new Client(baseUrl: $this->server->url, transport: new StreamTransport()); + $this->server->respond('/01001000', [self::json(self::SE_ADDRESS)]); + + $address = $client->lookup('01001000'); + + self::assertSame('São Paulo', $address->localidade); + self::assertSame(1, $this->server->countCalls('/01001000')); + } + + public function testTheClientSendsItsIdentifyingHeaders(): void + { + $transport = new RecordingTransport([new Response(200, [], '{"cep":"01001000"}')]); + $client = new Client(baseUrl: 'https://example.invalid', timeout: 3.5, transport: $transport); + + $client->lookup('01001000'); + + self::assertSame('https://example.invalid/01001000', $transport->calls[0]['url']); + self::assertStringContainsString('cepis-php/', $transport->calls[0]['headers']['User-Agent']); + self::assertArrayHasKey('Accept', $transport->calls[0]['headers']); + self::assertSame(3.5, $transport->calls[0]['timeout']); + } + + public function testATransportThrowingNetworkExceptionIsRetried(): void + { + $transport = new RecordingTransport([]); + $client = new Client( + baseUrl: 'https://example.invalid', + maxAttempts: 3, + backoffBase: 0.001, + backoffMax: 0.01, + transport: $transport, + ); + + try { + $client->lookup('01001000'); + self::fail('expected NetworkException'); + } catch (NetworkException) { + self::assertCount(3, $transport->calls); + } + } + + public function testATransportReturningA503IsRetriedNotThrown(): void + { + $transport = new RecordingTransport([ + new Response(503, [], '{}'), + new Response(503, [], '{}'), + new Response(200, [], json_encode(self::SE_ADDRESS, JSON_THROW_ON_ERROR)), + ]); + $client = new Client( + baseUrl: 'https://example.invalid', + backoffBase: 0.001, + backoffMax: 0.01, + transport: $transport, + ); + + self::assertSame('São Paulo', $client->lookup('01001000')->localidade); + self::assertCount(3, $transport->calls); + } + + public function testA404FromATransportIsNotRetried(): void + { + $transport = new RecordingTransport([new Response(404, [], '{}')]); + $client = new Client(baseUrl: 'https://example.invalid', transport: $transport); + + try { + $client->lookup('99999999'); + self::fail('expected CepNotFoundException'); + } catch (CepNotFoundException) { + self::assertCount(1, $transport->calls); + } + } + + public function testRetryAfterIsReadFromATransportSuppliedHeader(): void + { + $transport = new RecordingTransport([new Response(429, ['retry-after' => ['5']], '{}')]); + $client = new Client( + baseUrl: 'https://example.invalid', + maxAttempts: 1, + transport: $transport, + ); + + try { + $client->lookup('01001000'); + self::fail('expected RateLimitedException'); + } catch (RateLimitedException $exception) { + self::assertEqualsWithDelta(5.0, $exception->retryAfter, 1e-9); + } + } + + public function testResponseHeaderLookupIsCaseInsensitiveAndTakesListValues(): void + { + $response = new Response(200, ['Retry-After' => '7', 'X-Multi' => ['a', 'b']], ''); + + self::assertSame('7', $response->header('retry-after')); + self::assertSame('7', $response->header('RETRY-AFTER')); + self::assertSame('a', $response->header('x-multi')); + self::assertNull($response->header('absent')); + } + + public function testServerErrorStillSurfacesThroughAnAlternativeTransport(): void + { + $client = new Client( + baseUrl: $this->server->url, + backoffBase: 0.001, + backoffMax: 0.01, + transport: new StreamTransport(), + ); + $this->server->respond('/01001000', [self::raw(500, '{}')]); + + $this->expectException(ServerErrorException::class); + $client->lookup('01001000'); + } +} diff --git a/tests/server/router.php b/tests/server/router.php new file mode 100644 index 0000000..e082027 --- /dev/null +++ b/tests/server/router.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +$state = getenv('CEPIS_TEST_STATE'); +if ($state === false) { + http_response_code(500); + echo 'CEPIS_TEST_STATE is not set'; + + return true; +} + +$uri = $_SERVER['REQUEST_URI'] ?? '/'; +file_put_contents($state . '/calls.log', $uri . "\n", FILE_APPEND | LOCK_EX); + +$routes = json_decode((string) file_get_contents($state . '/routes.json'), true); +$routes = is_array($routes) ? $routes : []; + +$specs = $routes[$uri] ?? $routes['*'] ?? null; +if (!is_array($specs) || $specs === []) { + header('Content-Type: application/json; charset=utf-8'); + http_response_code(404); + echo json_encode(['erro' => 'cep_nao_encontrado', 'mensagem' => 'no route registered']); + + return true; +} + +$calls = array_filter(explode("\n", (string) file_get_contents($state . '/calls.log')), 'strlen'); +$seen = count(array_filter($calls, static fn (string $line): bool => $line === $uri)); +$spec = $specs[min(max($seen - 1, 0), count($specs) - 1)]; + +if (($spec['kind'] ?? 'static') === 'csv_echo') { + parse_str((string) parse_url($uri, PHP_URL_QUERY), $query); + $ceps = array_filter(explode(',', (string) ($query['ceps'] ?? '')), 'strlen'); + $rows = ['cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi']; + foreach ($ceps as $cep) { + $rows[] = $cep . ',Rua Teste,,,Centro,Cidade,SP,São Paulo,Sudeste,1,,11,1'; + } + header('Content-Type: text/csv; charset=utf-8'); + http_response_code(200); + echo implode("\n", $rows) . "\n"; + + return true; +} + +foreach (($spec['headers'] ?? []) as $name => $value) { + header($name . ': ' . $value); +} +header('Content-Type: ' . ($spec['content_type'] ?? 'text/plain; charset=utf-8')); +http_response_code((int) ($spec['status'] ?? 200)); +echo (string) ($spec['body'] ?? ''); + +return true; |