diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/helper/server.R | 114 | ||||
| -rw-r--r-- | tests/helper/utils.R | 127 | ||||
| -rw-r--r-- | tests/test-batch.R | 78 | ||||
| -rw-r--r-- | tests/test-config.R | 27 | ||||
| -rw-r--r-- | tests/test-health.R | 33 | ||||
| -rw-r--r-- | tests/test-lookup.R | 124 | ||||
| -rw-r--r-- | tests/test-normalize.R | 39 | ||||
| -rw-r--r-- | tests/test-retry.R | 121 |
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) + } +) |