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.