summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/helper/server.R114
-rw-r--r--tests/helper/utils.R127
-rw-r--r--tests/test-batch.R78
-rw-r--r--tests/test-config.R27
-rw-r--r--tests/test-health.R33
-rw-r--r--tests/test-lookup.R124
-rw-r--r--tests/test-normalize.R39
-rw-r--r--tests/test-retry.R121
8 files changed, 663 insertions, 0 deletions
diff --git a/tests/helper/server.R b/tests/helper/server.R
new file mode 100644
index 0000000..48f9a39
--- /dev/null
+++ b/tests/helper/server.R
@@ -0,0 +1,114 @@
+args <- commandArgs(trailingOnly = TRUE)
+routes <- readRDS(args[[1L]])
+logfile <- args[[2L]]
+readyfile <- args[[3L]]
+
+server <- NULL
+port <- NA_integer_
+for (candidate in sample(20000:60000, 500L)) {
+ server <- tryCatch(serverSocket(candidate), error = function(e) NULL)
+ if (!is.null(server)) {
+ port <- candidate
+ break
+ }
+}
+if (is.null(server)) {
+ stop("no free port for the fake server")
+}
+
+state <- new.env(parent = emptyenv())
+state$queues <- lapply(routes, function(route) route$responses)
+
+pick <- function(path) {
+ index <- NA_integer_
+ for (i in seq_along(routes)) {
+ if (identical(routes[[i]]$match, path)) {
+ index <- i
+ break
+ }
+ }
+ if (is.na(index)) {
+ for (i in seq_along(routes)) {
+ if (startsWith(path, routes[[i]]$match)) {
+ index <- i
+ break
+ }
+ }
+ }
+ if (is.na(index)) {
+ return(list(
+ status = 404L,
+ body = "{\"erro\":\"cep_nao_encontrado\"}",
+ headers = list()
+ ))
+ }
+ queue <- state$queues[[index]]
+ if (length(queue) > 1L) {
+ state$queues[[index]] <- queue[-1L]
+ }
+ queue[[1L]]
+}
+
+send <- function(connection, response) {
+ body <- charToRaw(enc2utf8(response$body))
+ head <- paste0("HTTP/1.1 ", response$status, " Response\r\n")
+ for (name in names(response$headers)) {
+ head <- paste0(head, name, ": ", response$headers[[name]], "\r\n")
+ }
+ head <- paste0(
+ head,
+ "Content-Type: text/plain; charset=utf-8\r\n",
+ "Content-Length: ", length(body), "\r\n",
+ "Connection: close\r\n\r\n"
+ )
+ try(
+ {
+ writeBin(c(charToRaw(head), body), connection)
+ flush(connection)
+ },
+ silent = TRUE
+ )
+}
+
+pending <- paste0(readyfile, ".tmp")
+writeLines(as.character(port), pending)
+file.rename(pending, readyfile)
+
+repeat {
+ if (!socketSelect(list(server), timeout = 60)) {
+ break
+ }
+ connection <- socketAccept(server, blocking = TRUE, open = "r+b",
+ timeout = 10)
+ path <- NA_character_
+ repeat {
+ line <- readLines(connection, n = 1L, warn = FALSE)
+ if (length(line) == 0L) {
+ break
+ }
+ line <- sub("\r$", "", line)
+ if (!nzchar(line)) {
+ break
+ }
+ if (is.na(path)) {
+ path <- strsplit(line, " ", fixed = TRUE)[[1L]][2L]
+ }
+ }
+
+ stopping <- FALSE
+ if (!is.na(path)) {
+ cat(path, "\n", sep = "", file = logfile, append = TRUE)
+ if (identical(path, "/__shutdown__")) {
+ stopping <- TRUE
+ send(connection, list(status = 200L, body = "bye", headers = list()))
+ } else {
+ send(connection, pick(path))
+ }
+ }
+ try(close(connection), silent = TRUE)
+ if (stopping) {
+ break
+ }
+}
+
+close(server)
diff --git a/tests/helper/utils.R b/tests/helper/utils.R
new file mode 100644
index 0000000..3c24a2f
--- /dev/null
+++ b/tests/helper/utils.R
@@ -0,0 +1,127 @@
+SERVER_SCRIPT <- normalizePath(file.path("helper", "server.R"), mustWork = TRUE)
+
+SE_ADDRESS <- list(
+ cep = "01001000",
+ logradouro = "Pra\u00e7a da S\u00e9",
+ complemento = "lado \u00edmpar",
+ unidade = "",
+ bairro = "S\u00e9",
+ localidade = "S\u00e3o Paulo",
+ uf = "SP",
+ estado = "S\u00e3o Paulo",
+ regiao = "Sudeste",
+ ibge = "3550308",
+ gia = "1004",
+ ddd = "11",
+ siafi = "7107"
+)
+
+CSV_HEADER <- paste0(
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,",
+ "ibge,gia,ddd,siafi"
+)
+
+json <- function(x) {
+ as.character(jsonlite::toJSON(x, auto_unbox = TRUE))
+}
+
+csv <- function(rows) {
+ paste0(CSV_HEADER, "\n", paste(rows, collapse = "\n"), "\n")
+}
+
+response <- function(status, body, headers = list()) {
+ list(status = status, body = body, headers = headers)
+}
+
+route <- function(match, ...) {
+ list(match = match, responses = list(...))
+}
+
+start_server <- function(routes) {
+ directory <- tempfile("cepis-fake-server-")
+ dir.create(directory)
+ routes_path <- file.path(directory, "routes.rds")
+ saveRDS(routes, routes_path)
+ logfile <- file.path(directory, "requests.log")
+ file.create(logfile)
+ readyfile <- file.path(directory, "ready")
+
+ system2(
+ file.path(R.home("bin"), "Rscript"),
+ shQuote(c(SERVER_SCRIPT, routes_path, logfile, readyfile)),
+ wait = FALSE,
+ stdout = file.path(directory, "stdout"),
+ stderr = file.path(directory, "stderr")
+ )
+
+ deadline <- Sys.time() + 30
+ while (!file.exists(readyfile) && Sys.time() < deadline) {
+ Sys.sleep(0.02)
+ }
+ if (!file.exists(readyfile)) {
+ stop(
+ "the fake server did not start: ",
+ paste(readLines(file.path(directory, "stderr"), warn = FALSE),
+ collapse = " ")
+ )
+ }
+
+ port <- readLines(readyfile, warn = FALSE)[[1L]]
+ list(
+ url = paste0("http://127.0.0.1:", port),
+ directory = directory,
+ logfile = logfile
+ )
+}
+
+stop_server <- function(server) {
+ try(
+ curl::curl_fetch_memory(paste0(server$url, "/__shutdown__")),
+ silent = TRUE
+ )
+ unlink(server$directory, recursive = TRUE)
+}
+
+calls <- function(server, prefix = "") {
+ lines <- readLines(server$logfile, warn = FALSE)
+ sum(startsWith(lines, prefix))
+}
+
+with_server <- function(routes, body) {
+ server <- start_server(routes)
+ on.exit(stop_server(server), add = TRUE)
+ client <- cepis_client(
+ base_url = server$url,
+ timeout = 5,
+ backoff_base = 0.001,
+ backoff_max = 0.01
+ )
+ body(client, server)
+}
+
+caught <- function(expr) {
+ tryCatch(
+ {
+ force(expr)
+ NULL
+ },
+ error = function(condition) condition
+ )
+}
+
+check <- function(label, condition) {
+ if (!isTRUE(condition)) {
+ stop("FAIL: ", label, call. = FALSE)
+ }
+ cat("ok - ", label, "\n", sep = "")
+ invisible(TRUE)
+}
+
+check_condition <- function(label, expr, class) {
+ condition <- caught(expr)
+ check(
+ label,
+ inherits(condition, class) && inherits(condition, "cepis_error")
+ )
+ invisible(condition)
+}
diff --git a/tests/test-batch.R b/tests/test-batch.R
new file mode 100644
index 0000000..6b28bcc
--- /dev/null
+++ b/tests/test-batch.R
@@ -0,0 +1,78 @@
+library(cepis)
+source(file.path("helper", "utils.R"))
+
+rows <- c(
+ paste0(
+ "01001000,Pra\u00e7a da S\u00e9,lado \u00edmpar,,S\u00e9,S\u00e3o Paulo,",
+ "SP,S\u00e3o Paulo,Sudeste,3550308,1004,11,7107"
+ ),
+ "99999999,,,,,,,,,,,,",
+ paste0(
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,",
+ "Sudeste,3304557,,21,6001"
+ )
+)
+
+with_server(
+ list(route("/csv?", response(200L, csv(rows)))),
+ function(client, server) {
+ result <- cep_lookup_many(c("01001000", "99999999", "20040020"), client)
+ check("the batch is as long as the input", length(result) == 3L)
+ check("the first CEP resolved", identical(result[[1L]]$localidade, "S\u00e3o Paulo"))
+ check("a missing CEP is NULL at its position", is.null(result[[2L]]))
+ check("the third CEP kept its position", identical(result[[3L]]$uf, "RJ"))
+ check("a batch address carries no coordinate", is.null(result[[3L]]$geo))
+ check("one request served the whole batch", calls(server, "/csv?") == 1L)
+ }
+)
+
+quoted <- paste0(
+ '01001000,"Rua Um, Dois",,,Centro,S\u00e3o Paulo,SP,S\u00e3o Paulo,',
+ "Sudeste,1,,11,1"
+)
+
+with_server(
+ list(route("/csv?", response(200L, csv(quoted)))),
+ function(client, server) {
+ result <- cep_lookup_many("01001000", client)
+ check(
+ "a quoted field containing a comma is parsed as one value",
+ identical(result[[1L]]$logradouro, "Rua Um, Dois")
+ )
+ }
+)
+
+with_server(
+ list(route("/csv?", response(200L, csv(character(0L))))),
+ function(client, server) {
+ check("an empty input returns an empty list", identical(cep_lookup_many(character(0L), client), list()))
+ check("and makes no request at all", calls(server) == 0L)
+ }
+)
+
+many <- sprintf("%08d", seq_len(501L) - 1L)
+chunk_row <- function(cep) paste0(cep, ",,,,,X,SP,S\u00e3o Paulo,Sudeste,1,,11,1")
+
+with_server(
+ list(route(
+ "/csv?",
+ response(200L, csv(vapply(many[1:500], chunk_row, character(1L)))),
+ response(200L, csv(vapply(many[501], chunk_row, character(1L))))
+ )),
+ function(client, server) {
+ result <- cep_lookup_many(many, client)
+ check("501 CEPs are split into two requests", calls(server, "/csv?") == 2L)
+ check("the result still has one entry per input", length(result) == 501L)
+ check("the last entry belongs to the last CEP", identical(result[[501L]]$cep, "00000500"))
+ }
+)
+
+with_server(
+ list(route("/csv?", response(200L, csv("01001000,,,,,S\u00e3o Paulo,SP,S\u00e3o Paulo,Sudeste,1,,11,1")))),
+ function(client, server) {
+ result <- cep_lookup_many(c("01001000", "20040020"), client)
+ check("a truncated response is padded, never shifted", length(result) == 2L)
+ check("the missing row became NULL", is.null(result[[2L]]))
+ check("the row that arrived kept its position", identical(result[[1L]]$cep, "01001000"))
+ }
+)
diff --git a/tests/test-config.R b/tests/test-config.R
new file mode 100644
index 0000000..57e3b3b
--- /dev/null
+++ b/tests/test-config.R
@@ -0,0 +1,27 @@
+library(cepis)
+source(file.path("helper", "utils.R"))
+
+check(
+ "a trailing slash is stripped from base_url",
+ identical(cepis_client(base_url = "http://x/")$base_url, "http://x")
+)
+check(
+ "the user agent identifies the library",
+ grepl("cepis-r/", cepis_client()$user_agent, fixed = TRUE)
+)
+check(
+ "max_attempts below one is rejected",
+ inherits(caught(cepis_client(max_attempts = 0L)), "error")
+)
+check(
+ "a timeout of zero is rejected",
+ inherits(caught(cepis_client(timeout = 0)), "error")
+)
+check(
+ "a total_timeout of zero is rejected",
+ inherits(caught(cepis_client(total_timeout = 0)), "error")
+)
+check(
+ "the default base_url is the public API",
+ identical(cepis_client()$base_url, "https://cep.is")
+)
diff --git a/tests/test-health.R b/tests/test-health.R
new file mode 100644
index 0000000..21007af
--- /dev/null
+++ b/tests/test-health.R
@@ -0,0 +1,33 @@
+library(cepis)
+source(file.path("helper", "utils.R"))
+
+healthy <- list(
+ status = "ok",
+ dne = "26071",
+ ceps = 1596168L,
+ geo = 894489L,
+ base_gerada_em = "2026-07-24T03:48:28Z"
+)
+
+with_server(
+ list(route("/health", response(200L, json(healthy)))),
+ function(client, server) {
+ health <- cep_health(client)
+ check("health reports itself ok", isTRUE(health$ok))
+ check("it names the database version", identical(health$dne, "26071"))
+ check("it counts the CEPs", identical(health$ceps, 1596168L))
+ check("it counts the ones with a coordinate", identical(health$geo, 894489L))
+ }
+)
+
+degraded <- list(status = "degradado", dne = "", ceps = 0L, geo = 0L)
+
+with_server(
+ list(route("/health", response(503L, json(degraded)))),
+ function(client, server) {
+ health <- cep_health(client)
+ check("a degraded API is a diagnosis, not an error", isFALSE(health$ok))
+ check("the status is readable", identical(health$status, "degradado"))
+ check("a 503 at /health is not retried", calls(server, "/health") == 1L)
+ }
+)
diff --git a/tests/test-lookup.R b/tests/test-lookup.R
new file mode 100644
index 0000000..0223f4d
--- /dev/null
+++ b/tests/test-lookup.R
@@ -0,0 +1,124 @@
+library(cepis)
+source(file.path("helper", "utils.R"))
+
+with_server(
+ list(route("/01001000", response(200L, json(SE_ADDRESS)))),
+ function(client, server) {
+ address <- cep_lookup("01001-000", client)
+ check("lookup reads the address", identical(address$localidade, "S\u00e3o Paulo"))
+ check("lookup reads the state", identical(address$uf, "SP"))
+ check("lookup keeps the CEP unmasked", identical(address$cep, "01001000"))
+ check("formatted_cep masks the address", identical(formatted_cep(address), "01001-000"))
+ check("an address without geo has geo NULL", is.null(address$geo))
+ check("the address is a cepis_address", inherits(address, "cepis_address"))
+ }
+)
+
+precise <- c(SE_ADDRESS, list(geo = list(
+ lat = -23.5, lon = -46.6, raio_m = 120L, precisao = "logradouro"
+)))
+with_server(
+ list(route("/01310100", response(200L, json(precise)))),
+ function(client, server) {
+ address <- cep_lookup("01310100", client)
+ check("the coordinate is read", isTRUE(all.equal(address$geo$lat, -23.5)))
+ check("raio_m is an integer", identical(address$geo$raio_m, 120L))
+ check("a street coordinate is precise", isTRUE(address$geo$is_precise))
+ }
+)
+
+municipality <- c(SE_ADDRESS, list(geo = list(
+ lat = -23.5, lon = -46.6, raio_m = 22000L, precisao = "localidade"
+)))
+with_server(
+ list(route("/01001000", response(200L, json(municipality)))),
+ function(client, server) {
+ address <- cep_lookup("01001000", client)
+ check(
+ "a municipality coordinate is not precise",
+ isFALSE(address$geo$is_precise)
+ )
+ }
+)
+
+broken <- c(SE_ADDRESS, list(geo = list(lat = "not a number")))
+with_server(
+ list(route("/01001000", response(200L, json(broken)))),
+ function(client, server) {
+ address <- cep_lookup("01001000", client)
+ check("a malformed geo is dropped", is.null(address$geo))
+ check(
+ "a malformed geo does not lose the address",
+ identical(address$localidade, "S\u00e3o Paulo")
+ )
+ }
+)
+
+grown <- c(SE_ADDRESS, list(campo_do_futuro = "value"))
+with_server(
+ list(route("/01001000", response(200L, json(grown)))),
+ function(client, server) {
+ address <- cep_lookup("01001000", client)
+ check(
+ "an unknown field is kept in raw",
+ identical(address$raw$campo_do_futuro, "value")
+ )
+ }
+)
+
+with_server(
+ list(route(
+ "/99999999",
+ response(404L, json(list(erro = "cep_nao_encontrado", mensagem = "gone")))
+ )),
+ function(client, server) {
+ condition <- check_condition(
+ "a missing CEP signals cepis_not_found",
+ cep_lookup("99999999", client),
+ "cepis_not_found"
+ )
+ check("the condition carries the CEP", identical(condition$cep, "99999999"))
+ check("404 is not retried, it is a definitive answer", calls(server, "/99999999") == 1L)
+ }
+)
+
+with_server(
+ list(route("/01001000", response(200L, json(SE_ADDRESS)))),
+ function(client, server) {
+ check_condition(
+ "a malformed CEP never leaves the machine",
+ cep_lookup("123", client),
+ "cepis_invalid_cep"
+ )
+ check("no request was made for it", calls(server) == 0L)
+ }
+)
+
+with_server(
+ list(route(
+ "/00000000",
+ response(400L, json(list(erro = "cep_invalido", mensagem = "rejected by the API")))
+ )),
+ function(client, server) {
+ condition <- check_condition(
+ "a 400 becomes cepis_invalid_cep",
+ cep_lookup("00000000", client),
+ "cepis_invalid_cep"
+ )
+ check(
+ "it carries the message the API sent",
+ grepl("rejected by the API", conditionMessage(condition), fixed = TRUE)
+ )
+ }
+)
+
+with_server(
+ list(route("/01001000", response(200L, "<html>captive portal</html>"))),
+ function(client, server) {
+ check_condition(
+ "a body that is not JSON signals cepis_invalid_response",
+ cep_lookup("01001000", client),
+ "cepis_invalid_response"
+ )
+ }
+)
diff --git a/tests/test-normalize.R b/tests/test-normalize.R
new file mode 100644
index 0000000..cbfe67f
--- /dev/null
+++ b/tests/test-normalize.R
@@ -0,0 +1,39 @@
+library(cepis)
+source(file.path("helper", "utils.R"))
+
+for (value in c("01001000", "01001-000", " 01001-000 ", "01001.000")) {
+ check(
+ paste0("normalize_cep accepts ", value),
+ identical(normalize_cep(value), "01001000")
+ )
+}
+
+for (value in c("", "123", "0100100a", "010010000", "abcdefgh")) {
+ check_condition(
+ paste0("normalize_cep rejects ", value),
+ normalize_cep(value),
+ "cepis_invalid_cep"
+ )
+}
+
+check_condition(
+ "normalize_cep rejects a number, which has lost its leading zero",
+ normalize_cep(1001000),
+ "cepis_invalid_cep"
+)
+
+check_condition(
+ "normalize_cep rejects a vector of length two",
+ normalize_cep(c("01001000", "20040020")),
+ "cepis_invalid_cep"
+)
+
+condition <- caught(normalize_cep("123"))
+check(
+ "the condition is catchable as cepis_error and as error",
+ inherits(condition, "cepis_error") && inherits(condition, "error")
+)
+check("the condition carries the offending value", identical(condition$value, "123"))
+
+check("formatted_cep adds the mask", identical(formatted_cep("01001000"), "01001-000"))
+check("formatted_cep leaves a non-CEP alone", identical(formatted_cep("x"), "x"))
diff --git a/tests/test-retry.R b/tests/test-retry.R
new file mode 100644
index 0000000..bb93d4e
--- /dev/null
+++ b/tests/test-retry.R
@@ -0,0 +1,121 @@
+library(cepis)
+source(file.path("helper", "utils.R"))
+
+with_server(
+ list(route(
+ "/01001000",
+ response(500L, json(list(erro = "interno"))),
+ response(200L, json(SE_ADDRESS))
+ )),
+ function(client, server) {
+ address <- cep_lookup("01001000", client)
+ check("a 500 is retried and the second attempt wins", identical(address$uf, "SP"))
+ check("it took exactly two attempts", calls(server, "/01001000") == 2L)
+ }
+)
+
+with_server(
+ list(route("/01001000", response(503L, json(list(erro = "interno"))))),
+ function(client, server) {
+ condition <- check_condition(
+ "a 5xx on every attempt signals cepis_server_error",
+ cep_lookup("01001000", client),
+ "cepis_server_error"
+ )
+ check("it exposes the status", identical(condition$status, 503L))
+ check("three attempts is the documented default", calls(server, "/01001000") == 3L)
+ }
+)
+
+with_server(
+ list(route("/01001000", response(500L, "{}"))),
+ function(client, server) {
+ single <- cepis_client(base_url = server$url, timeout = 5, max_attempts = 1L)
+ check_condition(
+ "max_attempts = 1 disables retrying",
+ cep_lookup("01001000", single),
+ "cepis_server_error"
+ )
+ check("only one request was made", calls(server, "/01001000") == 1L)
+ }
+)
+
+with_server(
+ list(route("/01001000", response(429L, "{}", list("Retry-After" = "7")))),
+ function(client, server) {
+ condition <- check_condition(
+ "a 429 signals cepis_rate_limited",
+ cep_lookup("01001000", client),
+ "cepis_rate_limited"
+ )
+ check("it exposes retry_after in seconds", isTRUE(all.equal(condition$retry_after, 7)))
+ }
+)
+
+with_server(
+ list(route("/01001000", response(429L, "{}", list("Retry-After" = "3600")))),
+ function(client, server) {
+ started <- proc.time()[["elapsed"]]
+ check_condition(
+ "an absurd Retry-After still signals cepis_rate_limited",
+ cep_lookup("01001000", client),
+ "cepis_rate_limited"
+ )
+ check(
+ "backoff_max caps it, so a bad proxy cannot park the session",
+ proc.time()[["elapsed"]] - started < 5
+ )
+ }
+)
+
+with_server(
+ list(route(
+ "/01001000",
+ response(429L, "{}", list("Retry-After" = "Wed, 21 Oct 2026 07:28:00 GMT"))
+ )),
+ function(client, server) {
+ condition <- check_condition(
+ "Retry-After as an HTTP date is understood",
+ cep_lookup("01001000", client),
+ "cepis_rate_limited"
+ )
+ check(
+ "the date became a positive number of seconds",
+ is.numeric(condition$retry_after) && condition$retry_after > 0
+ )
+ }
+)
+
+refused <- cepis_client(
+ base_url = "http://127.0.0.1:1",
+ timeout = 1,
+ max_attempts = 2L,
+ backoff_base = 0.001
+)
+check_condition(
+ "a refused connection signals cepis_network_error",
+ cep_lookup("01001000", refused),
+ "cepis_network_error"
+)
+
+with_server(
+ list(route("/01001000", response(500L, "{}"))),
+ function(client, server) {
+ bounded <- cepis_client(
+ base_url = server$url,
+ timeout = 5,
+ max_attempts = 50L,
+ backoff_base = 0.05,
+ backoff_max = 0.05,
+ total_timeout = 0.3
+ )
+ started <- proc.time()[["elapsed"]]
+ check_condition(
+ "total_timeout still ends in cepis_server_error",
+ cep_lookup("01001000", bounded),
+ "cepis_server_error"
+ )
+ check("it bounded the whole call", proc.time()[["elapsed"]] - started < 5)
+ check("it cut the run short of the 50 attempts", calls(server, "/01001000") < 50L)
+ }
+)