summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 14:58:55 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 14:58:55 -0300
commit64764fb9fac0227f8e0443e7c7519d73aec275b9 (patch)
treecb4fc56eeb6ecce53854f9c530609cf01ab3349f /tests
downloadphp-64764fb9fac0227f8e0443e7c7519d73aec275b9.tar.gz
Initial importv0.1.0
Diffstat (limited to 'tests')
-rw-r--r--tests/BatchTest.php139
-rw-r--r--tests/ConfigurationTest.php60
-rw-r--r--tests/HealthTest.php41
-rw-r--r--tests/LookupTest.php154
-rw-r--r--tests/NormalizationTest.php44
-rw-r--r--tests/RecordingTransport.php39
-rw-r--r--tests/RetryTest.php127
-rw-r--r--tests/ServerTestCase.php106
-rw-r--r--tests/StreamTransport.php52
-rw-r--r--tests/TestServer.php148
-rw-r--r--tests/TransportTest.php141
-rw-r--r--tests/server/router.php53
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;