summaryrefslogtreecommitdiffstats
path: root/src/test
diff options
context:
space:
mode:
Diffstat (limited to 'src/test')
-rw-r--r--src/test/java/is/cep/cepis/Assertions.java92
-rw-r--r--src/test/java/is/cep/cepis/BatchTest.java119
-rw-r--r--src/test/java/is/cep/cepis/ConfigurationTest.java52
-rw-r--r--src/test/java/is/cep/cepis/CsvTest.java60
-rw-r--r--src/test/java/is/cep/cepis/FakeServer.java120
-rw-r--r--src/test/java/is/cep/cepis/HealthTest.java40
-rw-r--r--src/test/java/is/cep/cepis/HttpTest.java59
-rw-r--r--src/test/java/is/cep/cepis/JsonTest.java96
-rw-r--r--src/test/java/is/cep/cepis/LookupTest.java127
-rw-r--r--src/test/java/is/cep/cepis/ModelTest.java78
-rw-r--r--src/test/java/is/cep/cepis/NormalizeTest.java38
-rw-r--r--src/test/java/is/cep/cepis/RetryTest.java105
-rw-r--r--src/test/java/is/cep/cepis/TestRunner.java108
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);
+ }
+ }
+}