context7-cli 0.2.0

CLI client for the Context7 API — search libraries and documentation from the terminal
Documentation
//! Testes de integração HTTP para o módulo `api`.
//!
//! Usa `wiremock` para montar um servidor HTTP local que simula a API Context7.
//! Nenhum teste faz requisição real à internet.
//!
//! Os testes acessam diretamente `context7_cli::api::*` via `[lib]` em Cargo.toml,
//! permitindo testar a lógica de HTTP, desserialização e retry sem invocar o binário.

use context7_cli::api::{
    criar_cliente_http, executar_com_retry, RespostaDocumentacao, RespostaListaBibliotecas,
};
use context7_cli::errors::ErroContext7;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

// ── Testes de buscar_biblioteca ───────────────────────────────────────────────

/// `buscar_biblioteca` retorna lista de bibliotecas quando o servidor responde 200.
#[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",
                "trust_score": 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;

    // Sobrescreve BASE_URL via cliente com URL do mock
    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);
}

/// `buscar_biblioteca` retorna lista vazia quando o servidor responde com results=[].
#[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");
}

/// Resposta 401 do servidor deve mapear para `ErroContext7::SemChavesApi`.
#[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();

    // Simula o comportamento de tratar_status_resposta via buscar_biblioteca com URL injetada
    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");
}

/// Resposta 429 (rate limit) deve ser tratada sem panic.
#[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");
}

// ── Testes de buscar_documentacao ────────────────────────────────────────────

/// `buscar_documentacao` retorna snippets JSON quando o servidor responde 200.
#[tokio::test]
async fn testa_buscar_documentacao_retorna_snippets_200() {
    let servidor = MockServer::start().await;

    let resposta_json = serde_json::json!({
        "id": "/facebook/react",
        "snippets": [
            {
                "content": "O useEffect é um hook para efeitos colaterais.",
                "type": "text",
                "source_urls": ["https://react.dev/reference/react/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!(snippets[0].content.contains("useEffect"));
}

/// `buscar_documentacao` aceita `content` (modo texto plano) em vez de snippets.
#[tokio::test]
async fn testa_buscar_documentacao_aceita_conteudo_texto_plano() {
    let servidor = MockServer::start().await;

    let resposta_json = serde_json::json!({
        "id": "/axum-rs/axum",
        "content": "Axum é 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_json(&resposta_json))
        .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");

    let dados: RespostaDocumentacao = resposta.json().await.expect("deve deserializar");
    assert!(dados.snippets.is_none(), "modo txt não deve ter snippets");
    let conteudo = dados.content.expect("modo txt deve ter content");
    assert!(conteudo.contains("Axum"));
}

/// Resposta 500 do servidor deve ser tratada sem panic (erro do servidor).
#[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");
}

// ── Testes de executar_com_retry ──────────────────────────────────────────────

/// `executar_com_retry` com uma chave válida retorna Ok na primeira tentativa.
#[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");
}

/// `executar_com_retry` com todas chaves inválidas retorna `SemChavesApi`.
#[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"
    );
}

/// `executar_com_retry` tenta a segunda chave quando a primeira falha com erro transitório.
#[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 {
                // Primeira tentativa falha com erro transitório (não auth)
                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}"
    );
}

/// `executar_com_retry` aborta imediatamente em erro 400 (não transitório).
#[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();
    // Deve propagar o erro 400, não o RetryEsgotado
    assert!(
        err.contains("400") || err.contains("bad request") || err.contains("Bad request"),
        "mensagem deve mencionar 400 ou bad request: {err}"
    );
}