diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:17:46 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:17:46 -0300 |
| commit | 4a8c3b675df4b1a6b29904fc2af60605002a7f9e (patch) | |
| tree | 5c3dcc6594603d242b6a284c1601fd1ffbfcc8f4 /tests/api.rs | |
| download | rust-4a8c3b675df4b1a6b29904fc2af60605002a7f9e.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'tests/api.rs')
| -rw-r--r-- | tests/api.rs | 643 |
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(); +} |