diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:20:42 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:20:42 -0300 |
| commit | 329f679cd359bcab92adf3366cf17711836d0c79 (patch) | |
| tree | 68f956deb2e0d5c3a25da20260ee3c0678a64cd6 /src/test | |
| download | java-0.1.0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'src/test')
| -rw-r--r-- | src/test/java/is/cep/cepis/Assertions.java | 92 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/BatchTest.java | 119 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/ConfigurationTest.java | 52 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/CsvTest.java | 60 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/FakeServer.java | 120 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/HealthTest.java | 40 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/HttpTest.java | 59 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/JsonTest.java | 96 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/LookupTest.java | 127 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/ModelTest.java | 78 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/NormalizeTest.java | 38 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/RetryTest.java | 105 | ||||
| -rw-r--r-- | src/test/java/is/cep/cepis/TestRunner.java | 108 |
13 files changed, 1094 insertions, 0 deletions
diff --git a/src/test/java/is/cep/cepis/Assertions.java b/src/test/java/is/cep/cepis/Assertions.java new file mode 100644 index 0000000..a9a9d5e --- /dev/null +++ b/src/test/java/is/cep/cepis/Assertions.java @@ -0,0 +1,92 @@ +package is.cep.cepis; + +import java.util.Objects; + +/** The handful of assertions this suite needs. */ +public final class Assertions { + + private Assertions() { + } + + /** Fail unconditionally. */ + public static void fail(String message) { + throw new AssertionError(message); + } + + /** Assert that a condition holds. */ + public static void assertTrue(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message + ": expected true"); + } + } + + /** Assert that a condition does not hold. */ + public static void assertFalse(boolean condition, String message) { + if (condition) { + throw new AssertionError(message + ": expected false"); + } + } + + /** Assert that two values are equal. */ + public static void assertEquals(Object expected, Object actual, String message) { + if (!Objects.equals(expected, actual)) { + throw new AssertionError(message + ": expected <" + expected + ">, got <" + actual + + ">"); + } + } + + /** Assert that two numbers are equal. */ + public static void assertEquals(long expected, long actual, String message) { + if (expected != actual) { + throw new AssertionError(message + ": expected <" + expected + ">, got <" + actual + + ">"); + } + } + + /** Assert that two reals are equal within a tolerance. */ + public static void assertEquals(double expected, double actual, double delta, + String message) { + if (Math.abs(expected - actual) > delta) { + throw new AssertionError(message + ": expected <" + expected + ">, got <" + actual + + ">"); + } + } + + /** Assert that a value is null. */ + public static void assertNull(Object value, String message) { + if (value != null) { + throw new AssertionError(message + ": expected null, got <" + value + ">"); + } + } + + /** Assert that a value is not null. */ + public static void assertNotNull(Object value, String message) { + if (value == null) { + throw new AssertionError(message + ": expected a value, got null"); + } + } + + /** Assert that some text contains a fragment. */ + public static void assertContains(String haystack, String needle, String message) { + if (haystack == null || !haystack.contains(needle)) { + throw new AssertionError(message + ": expected <" + haystack + "> to contain <" + + needle + ">"); + } + } + + /** Assert that the body throws the expected exception, and return it. */ + public static <T extends Throwable> T assertThrows(Class<T> expected, Runnable body, + String message) { + try { + body.run(); + } catch (Throwable thrown) { + if (expected.isInstance(thrown)) { + return expected.cast(thrown); + } + throw new AssertionError(message + ": expected " + expected.getSimpleName() + + ", got " + thrown, thrown); + } + throw new AssertionError(message + ": expected " + expected.getSimpleName() + + ", nothing was thrown"); + } +} diff --git a/src/test/java/is/cep/cepis/BatchTest.java b/src/test/java/is/cep/cepis/BatchTest.java new file mode 100644 index 0000000..4b18c35 --- /dev/null +++ b/src/test/java/is/cep/cepis/BatchTest.java @@ -0,0 +1,119 @@ +package is.cep.cepis; + +import static is.cep.cepis.Assertions.assertEquals; +import static is.cep.cepis.Assertions.assertNotNull; +import static is.cep.cepis.Assertions.assertNull; + +import is.cep.cepis.FakeServer.Reply; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; + +/** The batch endpoint: order, gaps, chunking and the missing coordinate. */ +public final class BatchTest extends HttpTest { + + /** A missing CEP becomes null at its position; it does not vanish. */ + public void testPreservesOrderAndMarksMissing() { + server.respond("/csv?ceps=01001000%2C99999999%2C20040020", Reply.of(200, 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"))); + + List<Address> result = client.lookupMany( + List.of("01001000", "99999999", "20040020")); + + assertEquals(3, result.size(), "one entry per input"); + assertNotNull(result.get(0), "the first was found"); + assertEquals("São Paulo", result.get(0).localidade(), "localidade"); + assertNull(result.get(1), "the missing one is null"); + assertNotNull(result.get(2), "the third was found"); + assertEquals("RJ", result.get(2).uf(), "uf"); + } + + /** The batch carries no coordinate, ever. */ + public void testBatchNeverCarriesGeo() { + server.respond("/csv?ceps=01001000", Reply.of(200, csv( + "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11," + + "7107"))); + List<Address> result = client.lookupMany(List.of("01001000")); + assertNull(result.get(0).geo(), "no geo column, no geo"); + } + + /** Values with commas are quoted, and splitting on commas would corrupt them. */ + public void testFieldWithCommaIsParsed() { + server.respond("/csv?ceps=01001000", Reply.of(200, csv( + "01001000,\"Rua Um, Dois\",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1"))); + List<Address> result = client.lookupMany(List.of("01001000")); + assertNotNull(result.get(0), "found"); + assertEquals("Rua Um, Dois", result.get(0).logradouro(), "the comma survived"); + } + + /** An embedded quote arrives doubled. */ + public void testFieldWithQuoteIsParsed() { + server.respond("/csv?ceps=01001000", Reply.of(200, csv( + "01001000,\"Rua \"\"A\"\"\",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1"))); + List<Address> result = client.lookupMany(List.of("01001000")); + assertEquals("Rua \"A\"", result.get(0).logradouro(), "the quotes survived"); + } + + /** An empty input costs no request. */ + public void testEmptyListMakesNoRequest() { + assertEquals(0, client.lookupMany(List.of()).size(), "an empty result"); + assertEquals(0, server.calls().size(), "no request was made"); + } + + /** 501 CEPs must become 2 requests, not 1 the API would reject. */ + public void testChunksAboveTheApiLimit() { + List<String> ceps = IntStream.range(0, 501) + .mapToObj(i -> String.format("%08d", i)) + .toList(); + AtomicInteger requests = new AtomicInteger(); + server.handler(path -> { + int nth = requests.incrementAndGet(); + List<String> slice = nth == 1 ? ceps.subList(0, 500) : ceps.subList(500, 501); + List<String> rows = new ArrayList<>(slice.size()); + for (String cep : slice) { + rows.add(cep + ",,,,,Cidade,SP,São Paulo,Sudeste,1,,11,1"); + } + return Reply.of(200, csv(rows.toArray(new String[0]))); + }); + + List<Address> result = client.lookupMany(ceps); + + assertEquals(2, requests.get(), "two requests"); + assertEquals(501, result.size(), "one entry per input"); + assertNotNull(result.get(500), "the 501st came from the second chunk"); + } + + /** Padding beats shifting: a shift pairs the wrong address, silently. */ + public void testTruncatedResponseDoesNotShift() { + server.respond("/csv?ceps=01001000%2C20040020", Reply.of(200, csv( + "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"))); + List<Address> result = client.lookupMany(List.of("01001000", "20040020")); + assertEquals(2, result.size(), "the length matches the input"); + assertNotNull(result.get(0), "the first is still the first"); + assertNull(result.get(1), "the absent one is null, not shifted"); + } + + /** A response longer than the request is trimmed rather than trusted. */ + public void testOverlongResponseIsTrimmed() { + server.respond("/csv?ceps=01001000", Reply.of(200, csv( + "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", + "20040020,,,,,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,2,,21,2"))); + List<Address> result = client.lookupMany(List.of("01001000")); + assertEquals(1, result.size(), "one in, one out"); + } + + /** A malformed CEP is null at its position rather than an exception. */ + public void testMalformedCepIsNullNotAnError() { + server.respond("/csv?ceps=01001000%2Cnope", Reply.of(200, csv( + "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", + "nope,,,,,,,,,,,,"))); + List<Address> result = client.lookupMany(List.of("01001000", "nope")); + assertEquals(2, result.size(), "nothing was dropped"); + assertNull(result.get(1), "the malformed one is null"); + } +} diff --git a/src/test/java/is/cep/cepis/ConfigurationTest.java b/src/test/java/is/cep/cepis/ConfigurationTest.java new file mode 100644 index 0000000..95fe5ca --- /dev/null +++ b/src/test/java/is/cep/cepis/ConfigurationTest.java @@ -0,0 +1,52 @@ +package is.cep.cepis; + +import static is.cep.cepis.Assertions.assertContains; +import static is.cep.cepis.Assertions.assertEquals; +import static is.cep.cepis.Assertions.assertNull; +import static is.cep.cepis.Assertions.assertThrows; + +import java.time.Duration; + +/** The builder and its defaults. */ +public final class ConfigurationTest { + + /** A trailing slash cannot produce a doubled separator. */ + public void testTrailingSlashIsNotDuplicated() { + assertEquals("http://x", Client.builder().baseUrl("http://x/").build().baseUrl(), + "one slash"); + assertEquals("http://x", Client.builder().baseUrl("http://x///").build().baseUrl(), + "several slashes"); + } + + /** The defaults are the documented ones. */ + public void testDefaults() { + Client client = Client.create(); + assertEquals(Client.DEFAULT_URL, client.baseUrl(), "baseUrl"); + assertEquals(Duration.ofSeconds(10), client.timeout(), "timeout"); + assertEquals(3, client.maxAttempts(), "maxAttempts"); + assertEquals(Duration.ofMillis(500), client.backoffBase(), "backoffBase"); + assertEquals(Duration.ofSeconds(8), client.backoffMax(), "backoffMax"); + assertNull(client.totalTimeout(), "totalTimeout is unset"); + assertEquals(500, Client.MAX_BATCH, "MAX_BATCH"); + } + + /** Settings out of range are refused at build time. */ + public void testInvalidParameters() { + assertThrows(IllegalArgumentException.class, + () -> Client.builder().maxAttempts(0).build(), "maxAttempts"); + assertThrows(IllegalArgumentException.class, + () -> Client.builder().timeout(Duration.ZERO).build(), "timeout"); + assertThrows(IllegalArgumentException.class, + () -> Client.builder().totalTimeout(Duration.ZERO).build(), "totalTimeout"); + assertThrows(IllegalArgumentException.class, + () -> Client.builder().baseUrl("").build(), "baseUrl"); + } + + /** The default User-Agent identifies the library and its version. */ + public void testUserAgentIdentifiesTheLibrary() { + assertContains(Client.create().userAgent(), "cepis-java/" + Cepis.VERSION, + "the default user agent"); + assertEquals("mine/1.0", Client.builder().userAgent("mine/1.0").build().userAgent(), + "an override"); + } +} diff --git a/src/test/java/is/cep/cepis/CsvTest.java b/src/test/java/is/cep/cepis/CsvTest.java new file mode 100644 index 0000000..61b5e09 --- /dev/null +++ b/src/test/java/is/cep/cepis/CsvTest.java @@ -0,0 +1,60 @@ +package is.cep.cepis; + +import static is.cep.cepis.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +/** The hand-written CSV reader. */ +public final class CsvTest { + + /** The header names the columns. */ + public void testMapsColumnsByHeader() { + List<Map<String, String>> rows = Csv.parse("a,b,c\n1,2,3\n"); + assertEquals(1, rows.size(), "one record"); + assertEquals("1", rows.get(0).get("a"), "a"); + assertEquals("3", rows.get(0).get("c"), "c"); + } + + /** Quoted values may contain commas, quotes and newlines. */ + public void testQuoting() { + List<Map<String, String>> rows = Csv.parse( + "a,b\n\"one, two\",\"say \"\"hi\"\"\"\n\"line\nbreak\",x\n"); + assertEquals("one, two", rows.get(0).get("a"), "an embedded comma"); + assertEquals("say \"hi\"", rows.get(0).get("b"), "an embedded quote"); + assertEquals("line\nbreak", rows.get(1).get("a"), "an embedded newline"); + } + + /** Empty values stay empty, and a trailing empty field is not dropped. */ + public void testEmptyFields() { + List<Map<String, String>> rows = Csv.parse("a,b,c\n1,,\n"); + assertEquals("", rows.get(0).get("b"), "b"); + assertEquals("", rows.get(0).get("c"), "the trailing field exists"); + } + + /** A short record is padded rather than misaligned. */ + public void testShortRecordIsPadded() { + List<Map<String, String>> rows = Csv.parse("a,b,c\n1\n"); + assertEquals(3, rows.get(0).size(), "three columns"); + assertEquals("", rows.get(0).get("c"), "the missing one is empty"); + } + + /** CRLF, LF and a final record without a newline all work. */ + public void testLineEndings() { + assertEquals(2, Csv.parse("a\r\n1\r\n2\r\n").size(), "CRLF"); + assertEquals(2, Csv.parse("a\n1\n2").size(), "no trailing newline"); + assertEquals(2, Csv.parse("a\r1\r2\r").size(), "bare CR"); + } + + /** A byte order mark does not become part of the first column's name. */ + public void testByteOrderMarkIsDropped() { + List<Map<String, String>> rows = Csv.parse("\uFEFFcep,uf\n01001000,SP\n"); + assertEquals("01001000", rows.get(0).get("cep"), "the header is clean"); + } + + /** Nothing in, nothing out. */ + public void testEmptyDocument() { + assertEquals(0, Csv.parse("").size(), "empty"); + assertEquals(0, Csv.parse("a,b,c\n").size(), "header only"); + } +} diff --git a/src/test/java/is/cep/cepis/FakeServer.java b/src/test/java/is/cep/cepis/FakeServer.java new file mode 100644 index 0000000..da1f1a1 --- /dev/null +++ b/src/test/java/is/cep/cepis/FakeServer.java @@ -0,0 +1,120 @@ +package is.cep.cepis; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Function; + +/** + * A throwaway HTTP server on a random loopback port, with programmable routes + * and a request log. + * + * <p>The tests point a real {@link Client} at it, so the code path they + * exercise is the one that runs in production, request construction and error + * body reading included. It doubles as the test that {@code baseUrl} works: if + * it did not, none of the suite would run. + */ +public final class FakeServer implements AutoCloseable { + + /** One canned response. */ + public record Reply(int status, String body, Map<String, String> headers) { + + /** A reply with no extra headers. */ + public static Reply of(int status, String body) { + return new Reply(status, body, Map.of()); + } + + /** A reply carrying one extra header. */ + public static Reply of(int status, String body, String header, String value) { + return new Reply(status, body, Map.of(header, value)); + } + } + + private final HttpServer server; + private final ExecutorService executor; + private final Map<String, Deque<Reply>> routes = new HashMap<>(); + private final List<String> calls = new ArrayList<>(); + private Function<String, Reply> handler; + + /** Start the server. */ + public FakeServer() { + try { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + } catch (IOException cannotBind) { + throw new IllegalStateException("could not start the test server", cannotBind); + } + executor = Executors.newCachedThreadPool(); + server.setExecutor(executor); + server.createContext("/", this::serve); + server.start(); + } + + /** Where the server listens. */ + public String url() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + /** + * Register the replies for a path. Each request consumes the next one; the + * last repeats once the list runs out, which is how retrying is tested. + */ + public synchronized void respond(String path, Reply... replies) { + routes.put(path, new ArrayDeque<>(List.of(replies))); + } + + /** Answer every request with this function instead of the routes. */ + public synchronized void handler(Function<String, Reply> handler) { + this.handler = handler; + } + + /** How many requests so far began with the prefix. */ + public synchronized int count(String prefix) { + return (int) calls.stream().filter(call -> call.startsWith(prefix)).count(); + } + + /** Every request path seen so far, in order. */ + public synchronized List<String> calls() { + return List.copyOf(calls); + } + + @Override + public void close() { + server.stop(0); + executor.shutdownNow(); + } + + private synchronized Reply next(String path) { + calls.add(path); + if (handler != null) { + return handler.apply(path); + } + Deque<Reply> queue = routes.get(path); + if (queue == null || queue.isEmpty()) { + return Reply.of(404, "{\"erro\":\"cep_nao_encontrado\"}"); + } + return queue.size() > 1 ? queue.pop() : queue.peek(); + } + + private void serve(HttpExchange exchange) throws IOException { + String query = exchange.getRequestURI().getRawQuery(); + String path = exchange.getRequestURI().getRawPath() + (query == null ? "" : "?" + query); + Reply reply = next(path); + byte[] body = reply.body().getBytes(StandardCharsets.UTF_8); + reply.headers().forEach((name, value) -> exchange.getResponseHeaders().add(name, value)); + exchange.sendResponseHeaders(reply.status(), body.length == 0 ? -1 : body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } +} diff --git a/src/test/java/is/cep/cepis/HealthTest.java b/src/test/java/is/cep/cepis/HealthTest.java new file mode 100644 index 0000000..7b1691a --- /dev/null +++ b/src/test/java/is/cep/cepis/HealthTest.java @@ -0,0 +1,40 @@ +package is.cep.cepis; + +import static is.cep.cepis.Assertions.assertEquals; +import static is.cep.cepis.Assertions.assertFalse; +import static is.cep.cepis.Assertions.assertTrue; + +import is.cep.cepis.FakeServer.Reply; + +/** The health endpoint. */ +public final class HealthTest extends HttpTest { + + /** A healthy API reports which database is live. */ + public void testReadsHealth() { + server.respond("/health", Reply.of(200, + "{\"status\":\"ok\",\"dne\":\"26071\",\"ceps\":1596168,\"geo\":894489," + + "\"base_gerada_em\":\"2026-07-24T03:48:28Z\"}")); + Health health = client.health(); + assertTrue(health.ok(), "ok"); + assertEquals("26071", health.dne(), "dne"); + assertEquals(1596168L, health.ceps(), "ceps"); + assertEquals(894489L, health.geo(), "geo"); + assertEquals("2026-07-24T03:48:28Z", health.baseGeradaEm(), "baseGeradaEm"); + } + + /** Checking health is when you want a diagnosis, not an exception. */ + public void testDegradedDoesNotThrow() { + server.respond("/health", Reply.of(503, + "{\"status\":\"degradado\",\"dne\":\"\",\"ceps\":0,\"geo\":0}")); + Health health = client.health(); + assertFalse(health.ok(), "not ok"); + assertEquals("degradado", health.status(), "the diagnosis reached the caller"); + } + + /** A 503 on /health is not retried into a ServerError. */ + public void testDegradedIsNotRetried() { + server.respond("/health", Reply.of(503, "{\"status\":\"degradado\"}")); + client.health(); + assertEquals(1, server.count("/health"), "exactly one request"); + } +} diff --git a/src/test/java/is/cep/cepis/HttpTest.java b/src/test/java/is/cep/cepis/HttpTest.java new file mode 100644 index 0000000..42038d4 --- /dev/null +++ b/src/test/java/is/cep/cepis/HttpTest.java @@ -0,0 +1,59 @@ +package is.cep.cepis; + +import java.time.Duration; + +/** + * Fixture with a fake server and a client whose backoff is near zero. + * + * <p>The backoff is tiny because what is under test is the retry logic, not + * the ability of {@link Thread#sleep(long)} to sleep. + */ +public abstract class HttpTest { + + /** The fake API, replaced before each test method. */ + protected FakeServer server; + + /** A client pointed at {@link #server}. */ + protected Client client; + + /** The JSON the API returns for 01001000, without a coordinate. */ + protected static final String 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"}"""; + + /** The header row of the batch endpoint. */ + protected static final String CSV_HEADER = + "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao," + + "ibge,gia,ddd,siafi"; + + /** Start the server and build the client. */ + public void setUp() { + server = new FakeServer(); + client = Client.builder() + .baseUrl(server.url()) + .timeout(Duration.ofSeconds(5)) + .backoffBase(Duration.ofMillis(1)) + .backoffMax(Duration.ofMillis(10)) + .build(); + } + + /** Stop the server. */ + public void tearDown() { + if (server != null) { + server.close(); + } + } + + /** The address JSON with an extra field spliced in. */ + protected static String addressWith(String field, String json) { + return SE_ADDRESS.substring(0, SE_ADDRESS.length() - 1) + + ",\"" + field + "\":" + json + "}"; + } + + /** A CSV document with the standard header and these rows. */ + protected static String csv(String... rows) { + return CSV_HEADER + "\n" + String.join("\n", rows) + "\n"; + } +} diff --git a/src/test/java/is/cep/cepis/JsonTest.java b/src/test/java/is/cep/cepis/JsonTest.java new file mode 100644 index 0000000..130594f --- /dev/null +++ b/src/test/java/is/cep/cepis/JsonTest.java @@ -0,0 +1,96 @@ +package is.cep.cepis; + +import static is.cep.cepis.Assertions.assertEquals; +import static is.cep.cepis.Assertions.assertThrows; +import static is.cep.cepis.Assertions.assertTrue; + +import java.nio.charset.CharacterCodingException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** The hand-written JSON reader. */ +public final class JsonTest { + + private static Map<?, ?> object(String text) { + return (Map<?, ?>) Json.parse(text); + } + + /** The shapes the API returns. */ + public void testParsesTheApiShapes() { + Map<?, ?> parsed = object( + "{\"cep\":\"01001000\",\"geo\":{\"lat\":-23.567202,\"lon\":-46.649659," + + "\"raio_m\":1084,\"precisao\":\"logradouro\"}}"); + assertEquals("01001000", parsed.get("cep"), "a string field"); + Map<?, ?> geo = (Map<?, ?>) parsed.get("geo"); + assertEquals(-23.567202, (Double) geo.get("lat"), 1e-9, "a negative real"); + assertEquals(1084L, geo.get("raio_m"), "an integer stays integral"); + } + + /** Escapes, including the ones a naive reader gets wrong. */ + public void testParsesEscapes() { + Map<?, ?> parsed = object( + "{\"a\":\"quote \\\" backslash \\\\ slash \\/ tab \\t newline \\n\"}"); + assertEquals("quote \" backslash \\ slash / tab \t newline \n", parsed.get("a"), + "every escape"); + } + + /** A \\u escape, and a surrogate pair for an astral character. */ + public void testParsesUnicodeEscapes() { + Map<?, ?> parsed = object("{\"a\":\"S\\u00e3o\",\"b\":\"\\ud83d\\ude00\"}"); + assertEquals("São", parsed.get("a"), "a basic plane escape"); + assertEquals("\uD83D\uDE00", parsed.get("b"), "a surrogate pair"); + assertEquals(1, ((String) parsed.get("b")).codePointCount(0, 2), "one code point"); + } + + /** UTF-8 bytes on the wire become the right characters. */ + public void testDecodesUtf8() throws Exception { + byte[] body = "{\"a\":\"Praça da Sé\"}".getBytes(StandardCharsets.UTF_8); + assertEquals("Praça da Sé", object(Json.decodeUtf8(body)).get("a"), "multibyte text"); + } + + /** Bytes that are not UTF-8 did not come from this API. */ + public void testRejectsInvalidUtf8() { + byte[] body = {'{', '"', 'a', '"', ':', '"', (byte) 0xFF, '"', '}'}; + try { + Json.decodeUtf8(body); + Assertions.fail("invalid UTF-8 must not decode"); + } catch (CharacterCodingException expected) { + assertTrue(true, "rejected"); + } + } + + /** Arrays, nesting, booleans and null. */ + public void testParsesTheRestOfTheGrammar() { + Map<?, ?> parsed = object( + "{\"a\":[1,2.5,true,false,null,{\"b\":[]}],\"c\":1e3,\"d\":-0.5E-2}"); + List<?> array = (List<?>) parsed.get("a"); + assertEquals(6, array.size(), "six elements"); + assertEquals(1L, array.get(0), "an integer"); + assertEquals(Boolean.TRUE, array.get(2), "true"); + assertTrue(array.get(4) == null, "null"); + assertEquals(1000.0, (Double) parsed.get("c"), 1e-9, "an exponent"); + assertEquals(-0.005, (Double) parsed.get("d"), 1e-12, "a negative exponent"); + } + + /** Whitespace between every token. */ + public void testIgnoresWhitespace() { + assertEquals("b", object(" { \"a\" : \"b\" } ").get("a"), "spaced out"); + } + + /** Malformed documents are errors, not guesses. */ + public void testRejectsMalformedDocuments() { + for (String text : new String[] { + "", "{", "{\"a\"}", "{\"a\":}", "{\"a\":1,}", "{'a':1}", "{\"a\":1}trailing", + "[1,]", "{\"a\":01}", "{\"a\":\"unterminated", "{\"a\":\\}", "nul", + }) { + assertThrows(Json.SyntaxException.class, () -> Json.parse(text), text); + } + } + + /** Nesting deep enough to blow the stack is refused first. */ + public void testRejectsDeepNesting() { + String deep = "[".repeat(200) + "]".repeat(200); + assertThrows(Json.SyntaxException.class, () -> Json.parse(deep), "too deep"); + } +} diff --git a/src/test/java/is/cep/cepis/LookupTest.java b/src/test/java/is/cep/cepis/LookupTest.java new file mode 100644 index 0000000..8934dca --- /dev/null +++ b/src/test/java/is/cep/cepis/LookupTest.java @@ -0,0 +1,127 @@ +package is.cep.cepis; + +import static is.cep.cepis.Assertions.assertContains; +import static is.cep.cepis.Assertions.assertEquals; +import static is.cep.cepis.Assertions.assertFalse; +import static is.cep.cepis.Assertions.assertNotNull; +import static is.cep.cepis.Assertions.assertNull; +import static is.cep.cepis.Assertions.assertThrows; +import static is.cep.cepis.Assertions.assertTrue; + +import is.cep.cepis.FakeServer.Reply; + +/** The single lookup. */ +public final class LookupTest extends HttpTest { + + /** A 200 becomes an address. */ + public void testSuccessfulLookup() { + server.respond("/01001000", Reply.of(200, SE_ADDRESS)); + Address address = client.lookup("01001-000"); + assertEquals("São Paulo", address.localidade(), "localidade"); + assertEquals("Praça da Sé", address.logradouro(), "logradouro"); + assertEquals("SP", address.uf(), "uf"); + assertEquals("01001-000", address.formattedCep(), "formattedCep"); + assertNull(address.geo(), "geo is absent"); + } + + /** The coordinate is read when the API sends one. */ + public void testReadsTheCoordinateWhenPresent() { + server.respond("/01310100", Reply.of(200, addressWith("geo", + "{\"lat\":-23.567202,\"lon\":-46.649659,\"raio_m\":120," + + "\"precisao\":\"logradouro\"}"))); + Address address = client.lookup("01310100"); + assertNotNull(address.geo(), "geo is present"); + assertEquals(-23.567202, address.geo().lat(), 1e-9, "lat"); + assertEquals(-46.649659, address.geo().lon(), 1e-9, "lon"); + assertEquals(120, address.geo().raioM(), "raioM"); + assertTrue(address.geo().isPrecise(), "a street coordinate is precise"); + } + + /** A municipality centroid is not a precise coordinate. */ + public void testMunicipalityCoordinateIsNotPrecise() { + server.respond("/01001000", Reply.of(200, addressWith("geo", + "{\"lat\":-23.5,\"lon\":-46.6,\"raio_m\":22000," + + "\"precisao\":\"localidade\"}"))); + Address address = client.lookup("01001000"); + assertNotNull(address.geo(), "geo is present"); + assertFalse(address.geo().isPrecise(), "a centroid is not precise"); + } + + /** A malformed geo block costs the coordinate, not the address. */ + public void testMalformedGeoDoesNotLoseTheAddress() { + server.respond("/01001000", + Reply.of(200, addressWith("geo", "{\"lat\":\"not a number\"}"))); + Address address = client.lookup("01001000"); + assertNull(address.geo(), "geo was dropped"); + assertEquals("São Paulo", address.localidade(), "the address survived"); + } + + /** A field the API grows is preserved rather than fatal. */ + public void testNewApiFieldDoesNotBreak() { + server.respond("/01001000", + Reply.of(200, addressWith("campo_do_futuro", "\"value\""))); + Address address = client.lookup("01001000"); + assertEquals("value", address.raw().get("campo_do_futuro"), "raw keeps it"); + assertEquals("São Paulo", address.localidade(), "the address still parses"); + } + + /** raw() is unmodifiable. */ + public void testRawIsUnmodifiable() { + server.respond("/01001000", Reply.of(200, SE_ADDRESS)); + Address address = client.lookup("01001000"); + assertThrows(UnsupportedOperationException.class, + () -> address.raw().put("x", "y"), "raw is read-only"); + } + + /** A 404 is a missing CEP. */ + public void testNotFound() { + server.respond("/99999999", Reply.of(404, + "{\"erro\":\"cep_nao_encontrado\",\"mensagem\":\"CEP não existe\"}")); + CepNotFoundException failure = assertThrows(CepNotFoundException.class, + () -> client.lookup("99999999"), "a missing CEP"); + assertContains(failure.getMessage(), "99999999", "the message names the CEP"); + } + + /** A 404 is definitive; retrying it only burns someone's quota. */ + public void testNotFoundIsNotRetried() { + server.respond("/99999999", Reply.of(404, "{\"erro\":\"cep_nao_encontrado\"}")); + assertThrows(CepNotFoundException.class, () -> client.lookup("99999999"), "missing"); + assertEquals(1, server.count("/99999999"), "exactly one request"); + } + + /** A malformed CEP never leaves the machine. */ + public void testInvalidCepNeverLeavesTheMachine() { + assertThrows(InvalidCepException.class, () -> client.lookup("123"), "too short"); + assertEquals(0, server.calls().size(), "no request was made"); + } + + /** A 400 carries the API's own message. */ + public void testApi400BecomesInvalidCep() { + server.respond("/00000000", Reply.of(400, + "{\"erro\":\"cep_invalido\",\"mensagem\":\"rejected by the API\"}")); + InvalidCepException failure = assertThrows(InvalidCepException.class, + () -> client.lookup("00000000"), "a rejected CEP"); + assertContains(failure.getMessage(), "rejected by the API", "the API's message"); + } + + /** A 400 is definitive too. */ + public void testInvalidIsNotRetried() { + server.respond("/00000000", Reply.of(400, "{\"erro\":\"cep_invalido\"}")); + assertThrows(InvalidCepException.class, () -> client.lookup("00000000"), "rejected"); + assertEquals(1, server.count("/00000000"), "exactly one request"); + } + + /** A captive portal answering instead of the API is diagnosed, not parsed. */ + public void testResponseThatIsNotJson() { + server.respond("/01001000", Reply.of(200, "<html>captive portal</html>")); + assertThrows(InvalidResponseException.class, + () -> client.lookup("01001000"), "an unreadable body"); + } + + /** A JSON array where an object was promised is not an address. */ + public void testResponseThatIsNotAnObject() { + server.respond("/01001000", Reply.of(200, "[]")); + assertThrows(InvalidResponseException.class, + () -> client.lookup("01001000"), "an array is not an address"); + } +} diff --git a/src/test/java/is/cep/cepis/ModelTest.java b/src/test/java/is/cep/cepis/ModelTest.java new file mode 100644 index 0000000..26deb5a --- /dev/null +++ b/src/test/java/is/cep/cepis/ModelTest.java @@ -0,0 +1,78 @@ +package is.cep.cepis; + +import static is.cep.cepis.Assertions.assertEquals; +import static is.cep.cepis.Assertions.assertFalse; +import static is.cep.cepis.Assertions.assertNull; +import static is.cep.cepis.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** The records, away from any HTTP. */ +public final class ModelTest { + + private static Address address(Geo geo, Map<String, Object> raw) { + return new Address("01001000", "Praça da Sé", "", "", "Sé", "São Paulo", "SP", + "São Paulo", "Sudeste", "3550308", "1004", "11", "7107", geo, raw); + } + + /** Formatting is presentation, and lives on the model. */ + public void testFormattedCep() { + assertEquals("01001-000", address(null, Map.of()).formattedCep(), "masked"); + } + + /** The raw response takes no part in equality. */ + public void testRawIsNotPartOfEquality() { + Address one = address(null, Map.of("campo_do_futuro", "a")); + Address two = address(null, Map.of()); + assertEquals(one, two, "the same place is the same address"); + assertEquals(one.hashCode(), two.hashCode(), "and hashes alike"); + assertFalse(one.toString().contains("campo_do_futuro"), "toString stays readable"); + } + + /** The coordinate does take part in equality. */ + public void testGeoIsPartOfEquality() { + Address one = address(new Geo(-23.5, -46.6, 120, "logradouro"), Map.of()); + Address two = address(null, Map.of()); + assertFalse(one.equals(two), "a coordinate is part of the address"); + } + + /** A JSON null in the raw response does not break the copy. */ + public void testRawToleratesNullValues() { + Map<String, Object> raw = new LinkedHashMap<>(); + raw.put("cep", "01001000"); + raw.put("novo", null); + Address parsed = Address.fromJson(raw); + assertTrue(parsed.raw().containsKey("novo"), "the null field is kept"); + assertNull(parsed.raw().get("novo"), "as null"); + } + + /** A field the API lacks is an empty string, never null. */ + public void testMissingFieldsAreEmptyStrings() { + Address parsed = Address.fromJson(Map.of("cep", "01001000")); + assertEquals("", parsed.logradouro(), "logradouro"); + assertEquals("", parsed.gia(), "gia"); + assertNull(parsed.geo(), "geo"); + } + + /** A geo block missing precisao is not a coordinate. */ + public void testGeoNeedsEveryField() { + Address parsed = Address.fromJson(Map.of("cep", "01001000", + "geo", Map.of("lat", -23.5, "lon", -46.6, "raio_m", 120))); + assertNull(parsed.geo(), "an incomplete block is dropped"); + } + + /** The precision check is the one that matters before measuring distance. */ + public void testIsPrecise() { + assertTrue(new Geo(-23.5, -46.6, 120, "logradouro").isPrecise(), "a street"); + assertFalse(new Geo(-23.5, -46.6, 22000, "localidade").isPrecise(), "a municipality"); + } + + /** Health tolerates a partial body rather than failing the check. */ + public void testHealthDefaults() { + Health health = Health.fromJson(Map.of("status", "ok")); + assertTrue(health.ok(), "ok"); + assertEquals(0L, health.ceps(), "ceps"); + assertEquals("", health.baseGeradaEm(), "baseGeradaEm"); + } +} diff --git a/src/test/java/is/cep/cepis/NormalizeTest.java b/src/test/java/is/cep/cepis/NormalizeTest.java new file mode 100644 index 0000000..b5dd729 --- /dev/null +++ b/src/test/java/is/cep/cepis/NormalizeTest.java @@ -0,0 +1,38 @@ +package is.cep.cepis; + +import static is.cep.cepis.Assertions.assertEquals; +import static is.cep.cepis.Assertions.assertThrows; + +/** Local validation, which runs before any request. */ +public final class NormalizeTest { + + /** The mask is optional and stripped. */ + public void testAcceptsMaskedAndUnmasked() { + for (String value : new String[] {"01001000", "01001-000", " 01001-000 ", "01001.000"}) { + assertEquals("01001000", Client.normalizeCep(value), value); + } + } + + /** Anything that is not eight digits is the caller's bug. */ + public void testRejectsBadFormat() { + for (String value : new String[] {"", "123", "0100100a", "010010000", "abcdefgh"}) { + assertThrows(InvalidCepException.class, () -> Client.normalizeCep(value), value); + } + } + + /** Null is rejected the same way, not with a NullPointerException. */ + public void testRejectsNull() { + assertThrows(InvalidCepException.class, () -> Client.normalizeCep(null), "null"); + } + + /** Non-ASCII digits are not digits. */ + public void testRejectsNonAsciiDigits() { + assertThrows(InvalidCepException.class, + () -> Client.normalizeCep("٠١٠٠١٠٠٠"), "arabic-indic digits"); + } + + /** Everything is catchable as one type. */ + public void testIsACepisException() { + assertThrows(CepisException.class, () -> Cepis.normalizeCep("123"), "one base type"); + } +} diff --git a/src/test/java/is/cep/cepis/RetryTest.java b/src/test/java/is/cep/cepis/RetryTest.java new file mode 100644 index 0000000..702a1ff --- /dev/null +++ b/src/test/java/is/cep/cepis/RetryTest.java @@ -0,0 +1,105 @@ +package is.cep.cepis; + +import static is.cep.cepis.Assertions.assertEquals; +import static is.cep.cepis.Assertions.assertThrows; +import static is.cep.cepis.Assertions.assertTrue; + +import is.cep.cepis.FakeServer.Reply; +import java.time.Duration; + +/** The retry policy: what is retried, how long it waits, and what it never retries. */ +public final class RetryTest extends HttpTest { + + /** A transient 500 is retried and the second attempt is used. */ + public void testRetriesOn500ThenSucceeds() { + server.respond("/01001000", + Reply.of(500, "{\"erro\":\"interno\"}"), + Reply.of(200, SE_ADDRESS)); + Address address = client.lookup("01001000"); + assertEquals("São Paulo", address.localidade(), "the retry succeeded"); + assertEquals(2, server.count("/01001000"), "two requests"); + } + + /** Three attempts is the documented default. */ + public void testExhaustsAttemptsThenThrows() { + server.respond("/01001000", Reply.of(503, "{\"erro\":\"interno\"}")); + ServerErrorException failure = assertThrows(ServerErrorException.class, + () -> client.lookup("01001000"), "the API is down"); + assertEquals(503, failure.status(), "the status is exposed"); + assertEquals(3, server.count("/01001000"), "three attempts"); + } + + /** maxAttempts of one disables retrying. */ + public void testMaxAttemptsOneDisablesRetry() { + Client single = Client.builder() + .baseUrl(server.url()) + .maxAttempts(1) + .timeout(Duration.ofSeconds(5)) + .build(); + server.respond("/01001000", Reply.of(500, "{}")); + assertThrows(ServerErrorException.class, () -> single.lookup("01001000"), "500"); + assertEquals(1, server.count("/01001000"), "exactly one attempt"); + } + + /** A 429 is a rate limit, and Retry-After reaches the caller. */ + public void test429BecomesRateLimitedWithRetryAfter() { + server.respond("/01001000", Reply.of(429, "{}", "Retry-After", "7")); + RateLimitedException failure = assertThrows(RateLimitedException.class, + () -> client.lookup("01001000"), "rate limited"); + assertEquals(Duration.ofSeconds(7), failure.retryAfter().orElse(null), "retryAfter"); + } + + /** A 429 with no Retry-After leaves the caller nothing to reschedule with. */ + public void test429WithoutRetryAfter() { + server.respond("/01001000", Reply.of(429, "{}")); + RateLimitedException failure = assertThrows(RateLimitedException.class, + () -> client.lookup("01001000"), "rate limited"); + assertTrue(failure.retryAfter().isEmpty(), "the server did not say"); + } + + /** An absurd Retry-After from a bad proxy must not park the thread. */ + public void testRetryAfterHonoursTheCap() { + server.respond("/01001000", Reply.of(429, "{}", "Retry-After", "3600")); + long started = System.nanoTime(); + assertThrows(RateLimitedException.class, () -> client.lookup("01001000"), "429"); + long elapsed = (System.nanoTime() - started) / 1_000_000L; + assertTrue(elapsed < 5_000, "capped at backoffMax, took " + elapsed + "ms"); + } + + /** Retry-After also arrives as an HTTP date. */ + public void testRetryAfterAcceptsAnHttpDate() { + Duration parsed = Client.parseRetryAfter("Wed, 21 Oct 2026 07:28:00 GMT"); + assertTrue(parsed != null, "an HTTP date is understood"); + assertTrue(parsed.toSeconds() >= 0, "never negative"); + assertEquals(null, Client.parseRetryAfter("not a date at all"), "nonsense is ignored"); + assertEquals(Duration.ofSeconds(120), Client.parseRetryAfter(" 120 "), "seconds"); + } + + /** Nothing listens on port 1, so the connection is refused outright. */ + public void testNetworkErrorWhenNothingListens() { + Client unreachable = Client.builder() + .baseUrl("http://127.0.0.1:1") + .maxAttempts(2) + .backoffBase(Duration.ofMillis(1)) + .timeout(Duration.ofSeconds(1)) + .build(); + assertThrows(NetworkException.class, () -> unreachable.lookup("01001000"), "refused"); + } + + /** The deadline cuts the run short of the fifty configured attempts. */ + public void testTotalTimeoutBoundsTheWholeCall() { + Client bounded = Client.builder() + .baseUrl(server.url()) + .maxAttempts(50) + .backoffBase(Duration.ofMillis(50)) + .backoffMax(Duration.ofMillis(50)) + .totalTimeout(Duration.ofMillis(300)) + .build(); + server.respond("/01001000", Reply.of(500, "{}")); + long started = System.nanoTime(); + assertThrows(ServerErrorException.class, () -> bounded.lookup("01001000"), "500"); + long elapsed = (System.nanoTime() - started) / 1_000_000L; + assertTrue(elapsed < 3_000, "the deadline held, took " + elapsed + "ms"); + assertTrue(server.count("/01001000") < 50, "it stopped short of 50 attempts"); + } +} diff --git a/src/test/java/is/cep/cepis/TestRunner.java b/src/test/java/is/cep/cepis/TestRunner.java new file mode 100644 index 0000000..a32ec57 --- /dev/null +++ b/src/test/java/is/cep/cepis/TestRunner.java @@ -0,0 +1,108 @@ +package is.cep.cepis; + +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.PrintStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +/** + * Runs the suite. + * + * <p>Every public no-argument method whose name starts with {@code test} is a + * test; {@code setUp} and {@code tearDown} run around each one, on a fresh + * instance. That is JUnit's model, minus JUnit — the library takes no + * dependencies, and the build is plain {@code javac}, so the suite is not the + * place to introduce a jar to download. + */ +public final class TestRunner { + + private static final PrintStream OUT = + new PrintStream(new FileOutputStream(FileDescriptor.out), true, + StandardCharsets.UTF_8); + + private static final List<Class<?>> SUITES = List.of( + NormalizeTest.class, + JsonTest.class, + CsvTest.class, + ModelTest.class, + ConfigurationTest.class, + LookupTest.class, + RetryTest.class, + BatchTest.class, + HealthTest.class); + + private TestRunner() { + } + + /** + * Run every suite and exit non-zero if anything failed. + * + * @param args ignored + * @throws ReflectiveOperationException if a suite cannot be instantiated + */ + public static void main(String[] args) throws ReflectiveOperationException { + int passed = 0; + List<String> failures = new ArrayList<>(); + long started = System.nanoTime(); + + for (Class<?> suite : SUITES) { + OUT.println(suite.getSimpleName()); + for (Method test : testsOf(suite)) { + Object instance = suite.getDeclaredConstructor().newInstance(); + String name = suite.getSimpleName() + "." + test.getName(); + try { + invoke(instance, "setUp"); + test.invoke(instance); + passed++; + OUT.println(" ok " + test.getName()); + } catch (InvocationTargetException failure) { + failures.add(name); + OUT.println(" FAIL " + test.getName()); + failure.getCause().printStackTrace(OUT); + } catch (RuntimeException | IllegalAccessException failure) { + failures.add(name); + OUT.println(" ERROR " + test.getName()); + failure.printStackTrace(OUT); + } finally { + invoke(instance, "tearDown"); + } + } + } + + long millis = (System.nanoTime() - started) / 1_000_000L; + OUT.println(); + OUT.println(passed + " passed, " + failures.size() + " failed, in " + millis + "ms"); + for (String failure : failures) { + OUT.println(" FAILED " + failure); + } + System.exit(failures.isEmpty() ? 0 : 1); + } + + private static List<Method> testsOf(Class<?> suite) { + return Arrays.stream(suite.getDeclaredMethods()) + .filter(method -> method.getName().startsWith("test")) + .filter(method -> method.getParameterCount() == 0) + .sorted(Comparator.comparing(Method::getName)) + .toList(); + } + + private static void invoke(Object instance, String name) { + Method method; + try { + method = instance.getClass().getMethod(name); + } catch (NoSuchMethodException absent) { + return; + } + try { + method.invoke(instance); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException(name + " failed", failure); + } + } +} |