use context7_cli::api::{
criar_cliente_http, executar_com_retry, DocumentationSnippet, LibrarySearchResult,
RespostaDocumentacao, RespostaListaBibliotecas,
};
use context7_cli::errors::ErroContext7;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn testa_buscar_biblioteca_retorna_resultados_200() {
let servidor = MockServer::start().await;
let resposta_json = serde_json::json!({
"results": [
{
"id": "/facebook/react",
"title": "React",
"description": "A JavaScript library for building user interfaces",
"trustScore": 95.0
}
]
});
Mock::given(method("GET"))
.and(path("/api/v1/search"))
.and(query_param("libraryName", "react"))
.respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
.mount(&servidor)
.await;
let url_base = servidor.uri();
let cliente = criar_cliente_http().unwrap();
let url = format!("{url_base}/api/v1/search");
let resposta = cliente
.get(&url)
.bearer_auth("ctx7sk-mock-token-12345678")
.query(&[("libraryName", "react"), ("query", "react")])
.send()
.await
.expect("deve conectar ao mock server");
assert!(resposta.status().is_success());
let dados: RespostaListaBibliotecas = resposta.json().await.expect("deve deserializar");
assert_eq!(dados.results.len(), 1);
assert_eq!(dados.results[0].id, "/facebook/react");
assert_eq!(dados.results[0].title, "React");
assert!((dados.results[0].trust_score.unwrap() - 95.0).abs() < f64::EPSILON);
}
#[tokio::test]
async fn testa_buscar_biblioteca_retorna_lista_vazia() {
let servidor = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/search"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "results": [] })),
)
.mount(&servidor)
.await;
let url_base = servidor.uri();
let cliente = criar_cliente_http().unwrap();
let url = format!("{url_base}/api/v1/search");
let resposta = cliente
.get(&url)
.bearer_auth("ctx7sk-mock-token-12345678")
.query(&[("libraryName", "inexistente"), ("query", "inexistente")])
.send()
.await
.expect("deve conectar ao mock server");
let dados: RespostaListaBibliotecas = resposta.json().await.expect("deve deserializar");
assert_eq!(dados.results.len(), 0, "lista deve estar vazia");
}
#[tokio::test]
async fn testa_buscar_biblioteca_401_mapeia_para_sem_chaves_api() {
let servidor = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/search"))
.respond_with(ResponseTemplate::new(401))
.mount(&servidor)
.await;
let url_base = servidor.uri();
let cliente = criar_cliente_http().unwrap();
let url = format!("{url_base}/api/v1/search");
let resposta = cliente
.get(&url)
.bearer_auth("ctx7sk-invalida")
.query(&[("libraryName", "react"), ("query", "react")])
.send()
.await
.expect("deve conectar ao mock server");
assert_eq!(resposta.status().as_u16(), 401, "mock deve retornar 401");
}
#[tokio::test]
async fn testa_buscar_biblioteca_429_nao_causa_panic() {
let servidor = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/search"))
.respond_with(ResponseTemplate::new(429))
.mount(&servidor)
.await;
let url_base = servidor.uri();
let cliente = criar_cliente_http().unwrap();
let url = format!("{url_base}/api/v1/search");
let resposta = cliente
.get(&url)
.bearer_auth("ctx7sk-mock-token-12345678")
.query(&[("libraryName", "react"), ("query", "react")])
.send()
.await
.expect("deve conectar ao mock server");
assert_eq!(resposta.status().as_u16(), 429, "mock deve retornar 429");
}
#[tokio::test]
async fn testa_buscar_documentacao_retorna_snippets_200() {
let servidor = MockServer::start().await;
let resposta_json = serde_json::json!({
"snippets": [
{
"pageTitle": "React Hooks",
"codeTitle": "useEffect hook",
"codeDescription": "O useEffect é um hook para efeitos colaterais.",
"codeLanguage": "javascript",
"codeList": [
{"language": "javascript", "code": "useEffect(() => {}, []);"}
]
}
]
});
Mock::given(method("GET"))
.and(path("/api/v1/facebook/react"))
.respond_with(ResponseTemplate::new(200).set_body_json(&resposta_json))
.mount(&servidor)
.await;
let url_base = servidor.uri();
let cliente = criar_cliente_http().unwrap();
let url = format!("{url_base}/api/v1/facebook/react");
let resposta = cliente
.get(&url)
.bearer_auth("ctx7sk-mock-token-12345678")
.query(&[("type", "json")])
.send()
.await
.expect("deve conectar ao mock server");
assert!(resposta.status().is_success());
let dados: RespostaDocumentacao = resposta.json().await.expect("deve deserializar");
let snippets = dados.snippets.expect("deve ter snippets");
assert_eq!(snippets.len(), 1);
assert_eq!(snippets[0].code_title.as_deref(), Some("useEffect hook"));
let lista = snippets[0].code_list.as_ref().expect("deve ter code_list");
assert!(lista[0].code.contains("useEffect"));
}
#[tokio::test]
async fn testa_buscar_documentacao_texto_retorna_conteudo_plano() {
let servidor = MockServer::start().await;
let conteudo_markdown = "# Axum\n\nAxum é um framework web para Rust baseado em Tower e Tokio.";
Mock::given(method("GET"))
.and(path("/api/v1/axum-rs/axum"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(conteudo_markdown)
.insert_header("content-type", "text/plain"),
)
.mount(&servidor)
.await;
let url_base = servidor.uri();
let cliente = criar_cliente_http().unwrap();
let url = format!("{url_base}/api/v1/axum-rs/axum");
let resposta = cliente
.get(&url)
.bearer_auth("ctx7sk-mock-token-12345678")
.query(&[("type", "txt")])
.send()
.await
.expect("deve conectar ao mock server");
assert!(resposta.status().is_success());
let texto = resposta.text().await.expect("deve ler texto");
assert!(texto.contains("Axum"), "conteúdo deve mencionar Axum");
}
#[tokio::test]
async fn testa_buscar_documentacao_500_nao_causa_panic() {
let servidor = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/rust-lang/rust"))
.respond_with(ResponseTemplate::new(500))
.mount(&servidor)
.await;
let url_base = servidor.uri();
let cliente = criar_cliente_http().unwrap();
let url = format!("{url_base}/api/v1/rust-lang/rust");
let resposta = cliente
.get(&url)
.bearer_auth("ctx7sk-mock-token-12345678")
.query(&[("type", "json")])
.send()
.await
.expect("deve conectar ao mock server");
assert_eq!(resposta.status().as_u16(), 500, "mock deve retornar 500");
}
#[tokio::test]
async fn testa_executar_com_retry_sucesso_na_primeira_tentativa() {
let chaves = vec!["ctx7sk-chave-valida-123456789012".to_string()];
let resultado = executar_com_retry(&chaves, |_chave| async move {
Ok::<String, ErroContext7>("sucesso".to_string())
})
.await;
assert!(resultado.is_ok(), "deve retornar Ok na primeira tentativa");
assert_eq!(resultado.unwrap(), "sucesso");
}
#[tokio::test]
async fn testa_executar_com_retry_todas_chaves_invalidas_retorna_sem_chaves() {
let chaves = vec![
"ctx7sk-invalida-01-123456789012".to_string(),
"ctx7sk-invalida-02-123456789012".to_string(),
"ctx7sk-invalida-03-123456789012".to_string(),
];
let resultado = executar_com_retry(&chaves, |_chave| async move {
Err::<String, ErroContext7>(ErroContext7::SemChavesApi)
})
.await;
assert!(
resultado.is_err(),
"deve falhar quando todas as chaves são inválidas"
);
}
#[tokio::test]
async fn testa_executar_com_retry_usa_segunda_chave_quando_primeira_falha() {
use std::sync::Arc;
use std::sync::Mutex;
let contador = Arc::new(Mutex::new(0usize));
let chaves = vec![
"ctx7sk-primeira-chave-123456789".to_string(),
"ctx7sk-segunda-chave-1234567890".to_string(),
];
let contador_clone = Arc::clone(&contador);
let resultado = executar_com_retry(&chaves, move |_chave| {
let cont = Arc::clone(&contador_clone);
async move {
let mut n = cont.lock().unwrap();
*n += 1;
let tentativa = *n;
drop(n);
if tentativa == 1 {
Err(ErroContext7::RespostaInvalida { status: 503 })
} else {
Ok::<String, ErroContext7>("sucesso na segunda".to_string())
}
}
})
.await;
assert!(resultado.is_ok(), "deve ter sucesso na segunda tentativa");
let tentativas = *contador.lock().unwrap();
assert!(
tentativas >= 2,
"deve ter feito pelo menos 2 tentativas, fez: {tentativas}"
);
}
#[tokio::test]
async fn testa_executar_com_retry_aborta_em_erro_400() {
let chaves = vec![
"ctx7sk-chave-01-123456789012345".to_string(),
"ctx7sk-chave-02-123456789012345".to_string(),
];
let resultado = executar_com_retry(&chaves, |_chave| async move {
Err::<String, ErroContext7>(ErroContext7::ApiRetornou400 {
mensagem: "bad request".to_string(),
})
})
.await;
assert!(resultado.is_err(), "deve falhar em 400");
let err = resultado.unwrap_err().to_string();
assert!(
err.contains("400") || err.contains("bad request") || err.contains("Bad request"),
"mensagem deve mencionar 400 ou bad request: {err}"
);
}
#[tokio::test]
async fn testa_retry_short_circuit_em_parse_error_status_200() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let contador_tentativas = Arc::new(AtomicUsize::new(0));
let chaves = vec![
"ctx7sk-chave-01-12345678901234".to_string(),
"ctx7sk-chave-02-12345678901234".to_string(),
"ctx7sk-chave-03-12345678901234".to_string(),
];
let cont_clone = Arc::clone(&contador_tentativas);
let resultado = executar_com_retry(&chaves, move |_chave| {
let cont = Arc::clone(&cont_clone);
async move {
cont.fetch_add(1, Ordering::SeqCst);
Err::<String, ErroContext7>(ErroContext7::RespostaInvalida { status: 200 })
}
})
.await;
assert!(resultado.is_err(), "deve falhar com parse error");
let tentativas_feitas = contador_tentativas.load(Ordering::SeqCst);
assert_eq!(
tentativas_feitas, 1,
"deve ter feito exatamente 1 tentativa antes de abortar (short-circuit), fez: {tentativas_feitas}"
);
}
#[tokio::test]
async fn testa_retry_usa_mais_de_3_chaves_se_disponiveis() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let contador = Arc::new(AtomicUsize::new(0));
let chaves: Vec<String> = (1..=5)
.map(|i| format!("ctx7sk-chave-{i:02}-12345678901234"))
.collect();
let cont_clone = Arc::clone(&contador);
let resultado = executar_com_retry(&chaves, move |_chave| {
let cont = Arc::clone(&cont_clone);
async move {
let n = cont.fetch_add(1, Ordering::SeqCst) + 1;
if n < 5 {
Err(ErroContext7::SemChavesApi)
} else {
Ok::<String, ErroContext7>("sucesso na 5ª chave".to_string())
}
}
})
.await;
assert!(
resultado.is_ok(),
"deve ter sucesso na 5ª chave, obteve: {:?}",
resultado.err().map(|e| e.to_string())
);
let tentativas = contador.load(Ordering::SeqCst);
assert_eq!(
tentativas, 5,
"deve ter tentado exatamente 5 chaves (v0.2.1 limite = 5), fez: {tentativas}"
);
}
#[test]
fn testa_deserializacao_library_search_result_camelcase_trust_score() {
let json = r#"{
"id": "/facebook/react",
"title": "React",
"trustScore": 95.0
}"#;
let resultado: LibrarySearchResult =
serde_json::from_str(json).expect("deve deserializar LibrarySearchResult com camelCase");
assert!(
resultado.trust_score.is_some(),
"trust_score deve ser Some após correção do camelCase (bug v0.2.0 era sempre None)"
);
assert!(
(resultado.trust_score.unwrap() - 95.0).abs() < f64::EPSILON,
"trust_score deve ser 95.0, obteve: {:?}",
resultado.trust_score
);
}
#[test]
fn testa_deserializacao_library_search_result_com_campos_extras() {
let json_str = r#"{
"id": "/rust-lang/rust",
"title": "Rust",
"trustScore": 9.8,
"stars": 97000,
"verified": true,
"totalSnippets": 500,
"totalTokens": 1000000
}"#;
let resultado: LibrarySearchResult =
serde_json::from_str(json_str).expect("deve desserializar com campos extras");
assert_eq!(resultado.id, "/rust-lang/rust");
assert_eq!(resultado.total_snippets, Some(500));
assert_eq!(resultado.stars, Some(97000));
assert_eq!(resultado.verified, Some(true));
assert_eq!(resultado.total_tokens, Some(1000000));
}
#[test]
fn testa_deserializacao_documentation_snippet_schema_v021() {
let json = r#"{
"codeTitle": "Test function",
"codeDescription": "A test description",
"codeLanguage": "rust",
"codeTokens": 10,
"codeId": "https://example.com/test",
"pageTitle": "Test page",
"codeList": [
{"language": "rust", "code": "fn main() {}"}
],
"relevance": 0.95,
"model": "gpt-4o"
}"#;
let snippet: DocumentationSnippet =
serde_json::from_str(json).expect("deve deserializar DocumentationSnippet v0.2.1");
assert_eq!(snippet.code_title.as_deref(), Some("Test function"));
assert_eq!(
snippet.code_description.as_deref(),
Some("A test description")
);
assert_eq!(snippet.code_language.as_deref(), Some("rust"));
assert_eq!(snippet.code_tokens, Some(10));
assert_eq!(snippet.code_id.as_deref(), Some("https://example.com/test"));
assert_eq!(snippet.page_title.as_deref(), Some("Test page"));
assert_eq!(snippet.relevance, Some(0.95));
assert_eq!(snippet.model.as_deref(), Some("gpt-4o"));
let lista = snippet.code_list.as_ref().expect("deve ter code_list");
assert_eq!(lista.len(), 1);
assert_eq!(lista[0].language, "rust");
assert_eq!(lista[0].code, "fn main() {}");
}
#[test]
fn testa_deserializacao_documentation_snippet_todos_campos_opcionais() {
let json = r#"{}"#;
let snippet: DocumentationSnippet =
serde_json::from_str(json).expect("deve deserializar snippet vazio sem erro");
assert!(snippet.code_title.is_none());
assert!(snippet.code_description.is_none());
assert!(snippet.code_language.is_none());
assert!(snippet.code_tokens.is_none());
assert!(snippet.code_id.is_none());
assert!(snippet.page_title.is_none());
assert!(snippet.code_list.is_none());
assert!(snippet.relevance.is_none());
assert!(snippet.model.is_none());
}
#[tokio::test]
async fn testa_executar_com_retry_short_circuita_em_biblioteca_nao_encontrada() {
use context7_cli::errors::ErroContext7;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let chaves = vec![
"ctx7sk-k1-12345678901234".to_string(),
"ctx7sk-k2-12345678901234".to_string(),
"ctx7sk-k3-12345678901234".to_string(),
];
let tentativas = Arc::new(AtomicUsize::new(0));
let tentativas_clone = Arc::clone(&tentativas);
let resultado: Result<(), _> = executar_com_retry(&chaves, move |_chave| {
let t = Arc::clone(&tentativas_clone);
async move {
t.fetch_add(1, Ordering::SeqCst);
Err::<(), _>(ErroContext7::BibliotecaNaoEncontrada {
library_id: "/teste/inexistente".to_string(),
})
}
})
.await;
assert!(resultado.is_err(), "deve retornar Err");
let msg = resultado.unwrap_err().to_string();
assert!(
msg.contains("not found") || msg.contains("/teste/inexistente"),
"erro deve mencionar library not found ou o library_id, obteve: {msg}"
);
assert_eq!(
tentativas.load(Ordering::SeqCst),
1,
"deve tentar APENAS 1 vez (short-circuit), não as {} chaves disponíveis",
chaves.len()
);
}
#[test]
fn testa_erro_biblioteca_nao_encontrada_display() {
let erro = ErroContext7::BibliotecaNaoEncontrada {
library_id: "/facebook/react".to_string(),
};
let msg = erro.to_string();
assert!(
msg.contains("/facebook/react"),
"mensagem deve conter o library_id, obteve: {msg}"
);
assert!(
msg.to_lowercase().contains("not found") || msg.to_lowercase().contains("library"),
"mensagem deve mencionar 'not found' ou 'library', obteve: {msg}"
);
}
#[test]
fn testa_library_search_result_aceita_stars_negativo() {
let json = r#"{
"id": "/websites/react_dev",
"title": "React",
"stars": -1,
"totalSnippets": 5724,
"totalTokens": 841799,
"trustScore": 8.5
}"#;
let r: LibrarySearchResult = serde_json::from_str(json)
.expect("Deve deserializar mesmo com stars: -1 (bug histórico v0.2.0→v0.2.1)");
assert_eq!(r.stars, Some(-1));
assert_eq!(r.trust_score, Some(8.5));
assert_eq!(r.id, "/websites/react_dev");
}
#[tokio::test]
async fn testa_buscar_documentacao_404_converte_para_biblioteca_nao_encontrada() {
let servidor = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/inexistente/lib"))
.respond_with(ResponseTemplate::new(404))
.mount(&servidor)
.await;
let url_base = servidor.uri();
let cliente = criar_cliente_http().unwrap();
let url = format!("{url_base}/api/v1/inexistente/lib");
let resposta = cliente
.get(&url)
.bearer_auth("ctx7sk-mock-token-12345678")
.query(&[("type", "json")])
.send()
.await
.expect("deve conectar ao mock server");
assert_eq!(
resposta.status().as_u16(),
404,
"mock deve retornar 404 para biblioteca inexistente"
);
}
#[tokio::test]
async fn testa_retry_nao_itera_chaves_quando_biblioteca_nao_existe() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let chaves: Vec<String> = (1..=5)
.map(|i| format!("ctx7sk-chave-{i:02}-12345678901234"))
.collect();
let tentativas = Arc::new(AtomicUsize::new(0));
let tentativas_clone = Arc::clone(&tentativas);
let resultado: Result<(), _> = executar_com_retry(&chaves, move |_chave| {
let t = Arc::clone(&tentativas_clone);
async move {
t.fetch_add(1, Ordering::SeqCst);
Err::<(), _>(ErroContext7::BibliotecaNaoEncontrada {
library_id: "/inexistente/lib".to_string(),
})
}
})
.await;
assert!(
resultado.is_err(),
"deve retornar Err para biblioteca inexistente"
);
assert_eq!(
tentativas.load(Ordering::SeqCst),
1,
"com BibliotecaNaoEncontrada, retry deve abortar após 1 tentativa (não rodar para todas as {} chaves)",
chaves.len()
);
let msg = resultado.unwrap_err().to_string();
assert!(
msg.contains("/inexistente/lib") || msg.contains("not found"),
"mensagem deve identificar a biblioteca inexistente: {msg}"
);
}