summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/api.rs643
1 files changed, 643 insertions, 0 deletions
diff --git a/tests/api.rs b/tests/api.rs
new file mode 100644
index 0000000..c794687
--- /dev/null
+++ b/tests/api.rs
@@ -0,0 +1,643 @@
+//! Integration tests.
+//!
+//! These bring up a real HTTP server on loopback and point the client at it via
+//! `base_url`. No test touches the network and no test fakes the HTTP layer:
+//! the code path exercised is the one that runs in production, request
+//! construction and error body reading included.
+//!
+//! It doubles as the test that `base_url` works — if it did not, none of this
+//! would run.
+//!
+//! The fixture field names stay Portuguese because they mirror the API's JSON.
+
+use std::collections::HashMap;
+use std::io::{BufRead, BufReader, Write};
+use std::net::{SocketAddr, TcpListener, TcpStream};
+use std::sync::{Arc, Mutex};
+use std::time::{Duration, Instant};
+
+use cepis::{Client, Error, normalize_cep};
+
+const SE_ADDRESS: &str = r#"{
+ "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"
+}"#;
+
+const CSV_HEADER: &str =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi";
+
+/// One canned response: status, body and extra headers.
+#[derive(Clone)]
+struct Reply {
+ status: u16,
+ body: String,
+ headers: Vec<(String, String)>,
+}
+
+impl Reply {
+ fn new(status: u16, body: &str) -> Reply {
+ Reply {
+ status,
+ body: body.to_owned(),
+ headers: Vec::new(),
+ }
+ }
+
+ fn header(mut self, name: &str, value: &str) -> Reply {
+ self.headers.push((name.to_owned(), value.to_owned()));
+ self
+ }
+}
+
+#[derive(Default)]
+struct State {
+ routes: HashMap<String, Vec<Reply>>,
+ calls: Vec<String>,
+}
+
+/// A throwaway HTTP server on a random loopback port.
+///
+/// Routes are matched by prefix, so `/csv` catches `/csv?ceps=...`. A route may
+/// hold several replies: each request consumes the next one, which is how
+/// retrying is tested, and the last one repeats once the list runs out.
+///
+/// The accept loop runs on a detached thread that ends with the test process;
+/// nothing here outlives `cargo test`.
+struct Server {
+ addr: SocketAddr,
+ state: Arc<Mutex<State>>,
+}
+
+impl Server {
+ fn start() -> Server {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
+ let addr = listener.local_addr().expect("local addr");
+ let state = Arc::new(Mutex::new(State::default()));
+ let shared = Arc::clone(&state);
+
+ std::thread::spawn(move || {
+ for stream in listener.incoming() {
+ match stream {
+ Ok(stream) => handle(stream, &shared),
+ Err(_) => break,
+ }
+ }
+ });
+
+ Server { addr, state }
+ }
+
+ fn url(&self) -> String {
+ format!("http://{}", self.addr)
+ }
+
+ fn client(&self) -> Client {
+ Client::builder()
+ .base_url(self.url())
+ .timeout(Duration::from_secs(5))
+ .backoff_base(Duration::from_millis(1))
+ .backoff_max(Duration::from_millis(10))
+ .build()
+ }
+
+ fn respond(&self, path: &str, replies: Vec<Reply>) {
+ self.state
+ .lock()
+ .unwrap()
+ .routes
+ .insert(path.to_owned(), replies);
+ }
+
+ fn calls(&self) -> usize {
+ self.state.lock().unwrap().calls.len()
+ }
+
+ fn count(&self, prefix: &str) -> usize {
+ self.state
+ .lock()
+ .unwrap()
+ .calls
+ .iter()
+ .filter(|c| c.starts_with(prefix))
+ .count()
+ }
+}
+
+fn handle(mut stream: TcpStream, state: &Arc<Mutex<State>>) {
+ let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
+ let mut request_line = String::new();
+ if reader.read_line(&mut request_line).is_err() || request_line.is_empty() {
+ return;
+ }
+ loop {
+ let mut header = String::new();
+ match reader.read_line(&mut header) {
+ Ok(0) => break,
+ Ok(_) if header == "\r\n" || header == "\n" => break,
+ Ok(_) => continue,
+ Err(_) => return,
+ }
+ }
+
+ let path = request_line
+ .split_whitespace()
+ .nth(1)
+ .unwrap_or("/")
+ .to_owned();
+
+ let reply = {
+ let mut state = state.lock().unwrap();
+ state.calls.push(path.clone());
+ let key = state
+ .routes
+ .keys()
+ .filter(|k| path.starts_with(*k))
+ .max_by_key(|k| k.len())
+ .cloned();
+ match key.and_then(|k| state.routes.get_mut(&k)) {
+ Some(replies) if replies.len() > 1 => replies.remove(0),
+ Some(replies) => replies[0].clone(),
+ None => Reply::new(404, r#"{"erro":"cep_nao_encontrado"}"#),
+ }
+ };
+
+ let body = reply.body.as_bytes();
+ let mut response = format!("HTTP/1.1 {} Status\r\n", reply.status);
+ for (name, value) in &reply.headers {
+ response.push_str(&format!("{name}: {value}\r\n"));
+ }
+ response.push_str(&format!("Content-Length: {}\r\n", body.len()));
+ response.push_str("Connection: close\r\n\r\n");
+
+ let _ = stream.write_all(response.as_bytes());
+ let _ = stream.write_all(body);
+ let _ = stream.flush();
+}
+
+fn csv_body(rows: &[String]) -> String {
+ format!("{}\n{}\n", CSV_HEADER, rows.join("\n"))
+}
+
+fn with_geo(precisao: &str, raio_m: i64) -> String {
+ format!(
+ r#"{{"cep":"01310100","logradouro":"Avenida Paulista","complemento":"","unidade":"","bairro":"Bela Vista","localidade":"São Paulo","uf":"SP","estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107","geo":{{"lat":-23.5,"lon":-46.6,"raio_m":{raio_m},"precisao":"{precisao}"}}}}"#
+ )
+}
+
+#[test]
+fn normalizes_masked_and_unmasked() {
+ for value in ["01001000", "01001-000", " 01001-000 ", "01001.000"] {
+ assert_eq!(normalize_cep(value).unwrap(), "01001000");
+ }
+}
+
+#[test]
+fn rejects_bad_format() {
+ for value in ["", "123", "0100100a", "010010000", "abcdefgh"] {
+ assert!(matches!(
+ normalize_cep(value),
+ Err(Error::InvalidCep { .. })
+ ));
+ }
+}
+
+#[test]
+fn successful_lookup() {
+ let server = Server::start();
+ server.respond("/01001000", vec![Reply::new(200, SE_ADDRESS)]);
+
+ let address = server.client().lookup("01001-000").unwrap();
+
+ assert_eq!(address.localidade, "São Paulo");
+ assert_eq!(address.uf, "SP");
+ assert_eq!(address.logradouro, "Praça da Sé");
+ assert_eq!(address.formatted_cep(), "01001-000");
+ assert!(address.geo.is_none());
+}
+
+#[test]
+fn reads_the_coordinate_when_present() {
+ let server = Server::start();
+ server.respond(
+ "/01310100",
+ vec![Reply::new(200, &with_geo("logradouro", 120))],
+ );
+
+ let address = server.client().lookup("01310100").unwrap();
+ let geo = address.geo.expect("geo present");
+
+ assert!((geo.lat + 23.5).abs() < 1e-9);
+ assert!((geo.lon + 46.6).abs() < 1e-9);
+ assert_eq!(geo.raio_m, 120);
+ assert!(geo.is_precise());
+}
+
+#[test]
+fn municipality_coordinate_is_not_precise() {
+ let server = Server::start();
+ server.respond(
+ "/01310100",
+ vec![Reply::new(200, &with_geo("localidade", 22000))],
+ );
+
+ let geo = server
+ .client()
+ .lookup("01310100")
+ .unwrap()
+ .geo
+ .expect("geo present");
+
+ assert!(!geo.is_precise());
+}
+
+#[test]
+fn malformed_geo_does_not_lose_the_address() {
+ let server = Server::start();
+ let body = SE_ADDRESS.replace(
+ r#""siafi": "7107""#,
+ r#""siafi": "7107", "geo": {"lat": "not a number"}"#,
+ );
+ server.respond("/01001000", vec![Reply::new(200, &body)]);
+
+ let address = server.client().lookup("01001000").unwrap();
+
+ assert!(address.geo.is_none());
+ assert_eq!(address.localidade, "São Paulo");
+}
+
+#[test]
+fn a_new_api_field_does_not_break_the_client() {
+ let server = Server::start();
+ let body = SE_ADDRESS.replace(
+ r#""siafi": "7107""#,
+ r#""siafi": "7107", "campo_do_futuro": "value""#,
+ );
+ server.respond("/01001000", vec![Reply::new(200, &body)]);
+
+ let address = server.client().lookup("01001000").unwrap();
+
+ assert_eq!(address.raw["campo_do_futuro"], "value");
+ assert_eq!(address.localidade, "São Paulo");
+}
+
+#[test]
+fn not_found() {
+ let server = Server::start();
+ server.respond(
+ "/99999999",
+ vec![Reply::new(
+ 404,
+ r#"{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}"#,
+ )],
+ );
+
+ let err = server.client().lookup("99999999").unwrap_err();
+
+ assert!(matches!(&err, Error::NotFound { cep } if cep == "99999999"));
+ assert!(!err.is_retriable());
+}
+
+#[test]
+fn not_found_is_not_retried() {
+ let server = Server::start();
+ server.respond("/99999999", vec![Reply::new(404, r#"{"erro":"x"}"#)]);
+
+ assert!(server.client().lookup("99999999").is_err());
+ assert_eq!(server.count("/99999999"), 1);
+}
+
+#[test]
+fn an_invalid_cep_never_leaves_the_machine() {
+ let server = Server::start();
+
+ let err = server.client().lookup("123").unwrap_err();
+
+ assert!(matches!(err, Error::InvalidCep { .. }));
+ assert_eq!(server.calls(), 0);
+}
+
+#[test]
+fn api_400_becomes_invalid_cep() {
+ let server = Server::start();
+ server.respond(
+ "/00000000",
+ vec![Reply::new(
+ 400,
+ r#"{"erro":"cep_invalido","mensagem":"rejected by the API"}"#,
+ )],
+ );
+
+ let err = server.client().lookup("00000000").unwrap_err();
+
+ assert!(matches!(err, Error::InvalidCep { .. }));
+ assert!(err.to_string().contains("rejected by the API"));
+ assert_eq!(server.count("/00000000"), 1);
+}
+
+#[test]
+fn a_response_that_is_not_json() {
+ let server = Server::start();
+ server.respond(
+ "/01001000",
+ vec![Reply::new(200, "<html>captive portal</html>")],
+ );
+
+ let err = server.client().lookup("01001000").unwrap_err();
+
+ assert!(matches!(err, Error::InvalidResponse { .. }));
+}
+
+#[test]
+fn retries_on_500_then_succeeds() {
+ let server = Server::start();
+ server.respond(
+ "/01001000",
+ vec![
+ Reply::new(500, r#"{"erro":"interno"}"#),
+ Reply::new(200, SE_ADDRESS),
+ ],
+ );
+
+ let address = server.client().lookup("01001000").unwrap();
+
+ assert_eq!(address.localidade, "São Paulo");
+ assert_eq!(server.count("/01001000"), 2);
+}
+
+#[test]
+fn exhausts_the_attempts_then_fails() {
+ let server = Server::start();
+ server.respond("/01001000", vec![Reply::new(503, r#"{"erro":"interno"}"#)]);
+
+ let err = server.client().lookup("01001000").unwrap_err();
+
+ assert!(matches!(err, Error::Server { status: 503, .. }));
+ assert!(err.is_retriable());
+ assert_eq!(server.count("/01001000"), 3);
+}
+
+#[test]
+fn max_attempts_one_disables_retrying() {
+ let server = Server::start();
+ server.respond("/01001000", vec![Reply::new(500, "{}")]);
+
+ let client = Client::builder()
+ .base_url(server.url())
+ .max_attempts(1)
+ .timeout(Duration::from_secs(5))
+ .build();
+
+ assert!(matches!(
+ client.lookup("01001000"),
+ Err(Error::Server { status: 500, .. })
+ ));
+ assert_eq!(server.count("/01001000"), 1);
+}
+
+#[test]
+fn rate_limited_exposes_retry_after() {
+ let server = Server::start();
+ server.respond(
+ "/01001000",
+ vec![Reply::new(429, "{}").header("Retry-After", "7")],
+ );
+
+ let err = server.client().lookup("01001000").unwrap_err();
+
+ match err {
+ Error::RateLimited { retry_after, .. } => {
+ assert_eq!(retry_after, Some(Duration::from_secs(7)));
+ }
+ other => panic!("expected RateLimited, got {other:?}"),
+ }
+}
+
+#[test]
+fn retry_after_honours_the_cap() {
+ let server = Server::start();
+ server.respond(
+ "/01001000",
+ vec![Reply::new(429, "{}").header("Retry-After", "3600")],
+ );
+
+ let started = Instant::now();
+ assert!(server.client().lookup("01001000").is_err());
+
+ assert!(started.elapsed() < Duration::from_secs(5));
+}
+
+#[test]
+fn retry_after_as_an_http_date_is_understood() {
+ let server = Server::start();
+ server.respond(
+ "/01001000",
+ vec![Reply::new(429, "{}").header("Retry-After", "Wed, 21 Oct 2099 07:28:00 GMT")],
+ );
+
+ let started = Instant::now();
+ let err = server.client().lookup("01001000").unwrap_err();
+
+ match err {
+ Error::RateLimited { retry_after, .. } => {
+ assert!(retry_after.expect("parsed") > Duration::from_secs(86_400));
+ }
+ other => panic!("expected RateLimited, got {other:?}"),
+ }
+ assert!(started.elapsed() < Duration::from_secs(5));
+}
+
+#[test]
+fn network_error_when_nothing_listens() {
+ let client = Client::builder()
+ .base_url("http://127.0.0.1:1")
+ .max_attempts(2)
+ .backoff_base(Duration::from_millis(1))
+ .timeout(Duration::from_secs(1))
+ .build();
+
+ let err = client.lookup("01001000").unwrap_err();
+
+ assert!(matches!(err, Error::Network { .. }));
+ assert!(err.is_retriable());
+}
+
+#[test]
+fn total_timeout_bounds_the_whole_call() {
+ let server = Server::start();
+ server.respond("/01001000", vec![Reply::new(500, "{}")]);
+
+ let client = Client::builder()
+ .base_url(server.url())
+ .timeout(Duration::from_secs(5))
+ .max_attempts(50)
+ .backoff_base(Duration::from_millis(50))
+ .backoff_max(Duration::from_millis(50))
+ .total_timeout(Some(Duration::from_millis(300)))
+ .build();
+
+ let started = Instant::now();
+ assert!(client.lookup("01001000").is_err());
+
+ assert!(started.elapsed() < Duration::from_secs(3));
+ assert!(server.count("/01001000") < 50);
+}
+
+#[test]
+fn batch_preserves_order_and_marks_missing() {
+ let server = Server::start();
+ let body = csv_body(&[
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107"
+ .to_owned(),
+ "99999999,,,,,,,,,,,,".to_owned(),
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001"
+ .to_owned(),
+ ]);
+ server.respond("/csv", vec![Reply::new(200, &body)]);
+
+ let result = server
+ .client()
+ .lookup_many(&["01001000", "99999999", "20040020"])
+ .unwrap();
+
+ assert_eq!(result.len(), 3);
+ assert_eq!(result[0].as_ref().unwrap().localidade, "São Paulo");
+ assert!(result[1].is_none());
+ assert_eq!(result[2].as_ref().unwrap().uf, "RJ");
+}
+
+#[test]
+fn batch_carries_no_coordinate() {
+ let server = Server::start();
+ let body = csv_body(&[
+ "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107".to_owned(),
+ ]);
+ server.respond("/csv", vec![Reply::new(200, &body)]);
+
+ let result = server.client().lookup_many(&["01001000"]).unwrap();
+
+ assert!(result[0].as_ref().unwrap().geo.is_none());
+}
+
+#[test]
+fn batch_parses_a_field_containing_a_comma() {
+ let server = Server::start();
+ let body = csv_body(&[
+ r#"01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1"#.to_owned(),
+ ]);
+ server.respond("/csv", vec![Reply::new(200, &body)]);
+
+ let result = server.client().lookup_many(&["01001000"]).unwrap();
+
+ assert_eq!(result[0].as_ref().unwrap().logradouro, "Rua Um, Dois");
+}
+
+#[test]
+fn an_empty_batch_makes_no_request() {
+ let server = Server::start();
+ let empty: [&str; 0] = [];
+
+ assert!(server.client().lookup_many(&empty).unwrap().is_empty());
+ assert_eq!(server.calls(), 0);
+}
+
+#[test]
+fn batch_chunks_above_the_api_limit() {
+ let server = Server::start();
+ let ceps: Vec<String> = (0..501).map(|i| format!("{i:08}")).collect();
+ let row = |c: &String| format!("{c},,,,,X,SP,São Paulo,Sudeste,1,,11,1");
+ let first: Vec<String> = ceps[..500].iter().map(row).collect();
+ let second: Vec<String> = ceps[500..].iter().map(row).collect();
+
+ server.respond(
+ "/csv",
+ vec![
+ Reply::new(200, &csv_body(&first)),
+ Reply::new(200, &csv_body(&second)),
+ ],
+ );
+
+ let result = server.client().lookup_many(&ceps).unwrap();
+
+ assert_eq!(result.len(), 501);
+ assert_eq!(server.count("/csv"), 2);
+ assert!(result.iter().all(|a| a.is_some()));
+}
+
+#[test]
+fn a_truncated_batch_pads_instead_of_shifting() {
+ let server = Server::start();
+ let body = csv_body(&["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1".to_owned()]);
+ server.respond("/csv", vec![Reply::new(200, &body)]);
+
+ let result = server
+ .client()
+ .lookup_many(&["01001000", "20040020"])
+ .unwrap();
+
+ assert_eq!(result.len(), 2);
+ assert!(result[0].is_some());
+ assert!(result[1].is_none());
+}
+
+#[test]
+fn reads_health() {
+ let server = Server::start();
+ server.respond(
+ "/health",
+ vec![Reply::new(
+ 200,
+ r#"{"status":"ok","dne":"26071","ceps":1596168,"geo":894489,"base_gerada_em":"2026-07-24T03:48:28Z"}"#,
+ )],
+ );
+
+ let health = server.client().health().unwrap();
+
+ assert!(health.ok());
+ assert_eq!(health.dne, "26071");
+ assert_eq!(health.ceps, 1_596_168);
+ assert_eq!(health.geo, 894_489);
+ assert_eq!(health.base_gerada_em, "2026-07-24T03:48:28Z");
+}
+
+#[test]
+fn a_degraded_api_is_not_an_error() {
+ let server = Server::start();
+ server.respond(
+ "/health",
+ vec![Reply::new(
+ 503,
+ r#"{"status":"degradado","dne":"","ceps":0,"geo":0}"#,
+ )],
+ );
+
+ let health = server.client().health().unwrap();
+
+ assert!(!health.ok());
+ assert_eq!(server.count("/health"), 1);
+}
+
+#[test]
+fn a_trailing_slash_is_not_duplicated() {
+ let client = Client::builder().base_url("http://x/").build();
+
+ assert_eq!(client.base_url(), "http://x");
+}
+
+#[test]
+fn the_user_agent_identifies_the_library() {
+ assert!(Client::new().user_agent().starts_with("cepis-rust/"));
+}
+
+#[test]
+#[should_panic(expected = "max_attempts must be >= 1")]
+fn zero_attempts_is_a_configuration_error() {
+ Client::builder().max_attempts(0).build();
+}
+
+#[test]
+#[should_panic(expected = "timeout must be > 0")]
+fn a_zero_timeout_is_a_configuration_error() {
+ Client::builder().timeout(Duration::ZERO).build();
+}