Skip to content

Test without a provider

rtb-chat's own test suite runs entirely against wiremock with no provider keys. The same approach works for code built on top of it.

This only works on the Anthropic-direct path, because that is the only backend whose endpoint rtb-chat controls. Config::base_url is ignored on genai providers, so an OpenAI or Ollama client cannot be pointed at a mock.

Point a client at a local mock server

Two fields do the work: base_url, and allow_insecure_base_url: true so the http:// URL a mock server hands you is accepted.

use std::time::Duration;
use rtb_chat::{AiClient, ChatRequest, Config, Message, Provider};
use secrecy::SecretString;
use url::Url;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

#[tokio::test]
async fn summarises_a_changelog() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/messages"))
        .and(header("anthropic-version", "2023-06-01"))
        .and(header("x-api-key", "test-key"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "id": "msg_01",
            "type": "message",
            "role": "assistant",
            "content": [{ "type": "text", "text": "hello, friend" }],
            "usage": { "input_tokens": 7, "output_tokens": 3 }
        })))
        .mount(&server)
        .await;

    let client = AiClient::new(Config {
        provider: Provider::Anthropic,
        model: "claude-opus-4-7".into(),
        base_url: Some(Url::parse(&server.uri()).unwrap()),
        api_key: SecretString::from("test-key".to_string()),
        timeout: Duration::from_secs(5),
        allow_insecure_base_url: true,
    })
    .expect("client builds");

    let resp = client
        .chat(ChatRequest {
            messages: vec![Message::user("hi")],
            ..ChatRequest::default()
        })
        .await
        .expect("chat succeeds");

    assert_eq!(resp.message.content[0].as_text(), Some("hello, friend"));
    assert_eq!(resp.usage.input_tokens, 7);
}

Without allow_insecure_base_url: true the client refuses to build:

invalid AI client config: base_url scheme "http" not permitted (set allow_insecure_base_url for tests)

Keep the flag in tests. It also disables https_only on the underlying HTTP client, so a production client carrying it will happily send your key in plaintext to any host.

Assert on the request body

Matching on path, method and headers proves the wire contract; matching on the body proves your request-building code. The Anthropic body shape is documented in Requests.

cache_control and thinking are the two worth pinning, because they are easy to set on the wrong field and produce a working call either way:

use wiremock::matchers::body_json;

Mock::given(body_json(serde_json::json!({
    "model": "claude-opus-4-7",
    "max_tokens": 1024,
    "system": [{ "type": "text", "text": "you are helpful",
                 "cache_control": { "type": "ephemeral" } }],
    "messages": [{ "role": "user", "content": [
        { "type": "text", "text": "hi",
          "cache_control": { "type": "ephemeral" } }] }]
})));

Remember max_tokens is present even when you did not set it — the default is 1024.

Fake a stream

Return an SSE body with content-type: text/event-stream. Events are separated by a blank line, and rtb-chat reads only the data: lines:

use wiremock::ResponseTemplate;

let sse = concat!(
    "event: message_start\n",
    "data: {\"type\":\"message_start\"}\n\n",
    "event: content_block_delta\n",
    "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n",
    "event: message_stop\n",
    "data: {\"type\":\"message_stop\"}\n\n",
);
let response = ResponseTemplate::new(200)
    .insert_header("content-type", "text/event-stream")
    .set_body_string(sse);

Use "thinking_delta" with a "thinking" field instead of "text" to produce ThinkingToken events.

Test the error paths

Every mapping is reachable from a mock, which makes the error handling as testable as the happy path:

To produce Respond with
AiError::Auth status 401 or 403
AiError::RateLimited with a hint status 429 and header retry-after: 5
AiError::Provider any other non-2xx, optionally {"error":{"message":"…"}}
AiError::Transport a delay longer than Config::timeout
AiError::SchemaValidation a 200 whose text is JSON of the wrong shape
AiError::Deserialize a 200 whose text is not JSON at all

Those variants are Anthropic-direct only. Code that also runs against genai providers should handle AiError::Provider as the catch-all it becomes there — see genai providers never return Auth or RateLimited.

Do not gate these behind an environment variable

There is no network and no credential involved, so these are ordinary unit tests. Keep them in the default cargo test run. Save the environment-variable gate for tests that really do reach a provider.