//! 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>, calls: Vec, } /// 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>, } 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) { 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>) { 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, "captive portal")], ); 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 = (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 = ceps[..500].iter().map(row).collect(); let second: Vec = 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(); }