test(integration): add wiremock coverage for ollama flows

Acceptance Criteria:\n- Local provider chat succeeds and records usage\n- Cloud tool-call scenario exercises web.search and usage tracking\n- Unauthorized and rate-limited cloud responses surface errors without recording usage\n\nTest Notes:\n- CARGO_NET_OFFLINE=true cargo test -p owlen-core --tests ollama_wiremock
This commit is contained in:
2025-10-24 23:56:38 +02:00
parent 16b6f24e3e
commit 6e12bb3acb
7 changed files with 481 additions and 0 deletions

View File

@@ -49,3 +49,4 @@ ollama-rs = { version = "0.3", features = ["stream", "headers"] }
[dev-dependencies] [dev-dependencies]
tokio-test = { workspace = true } tokio-test = { workspace = true }
httpmock = "0.7" httpmock = "0.7"
wiremock = "0.6"

View File

@@ -0,0 +1,11 @@
# Integration Fixtures
This directory holds canned Ollama responses used by the Wiremock-backed
integration tests in `ollama_wiremock.rs`. Each file mirrors the JSON payloads
served by the real Ollama HTTP API so the tests can stub
`/api/tags`, `/api/chat`, and `/v1/web/search` without contacting the network.
- `ollama_tags.json` minimal model listing shared by the local and cloud scenarios.
- `ollama_local_completion.json` non-streaming completion for the local provider.
- `ollama_cloud_tool_call.json` first chat turn that requests the `web_search` tool.
- `ollama_cloud_final.json` follow-up completion after the tool result is injected.

View File

@@ -0,0 +1,18 @@
{
"model": "llama3:8b-cloud",
"created_at": "2025-10-23T12:05:05Z",
"message": {
"role": "assistant",
"content": "Rust 1.85 shipped today. Summarising the highlights now.",
"tool_calls": []
},
"done": true,
"final_data": {
"total_duration": 2500000000,
"load_duration": 60000000,
"prompt_eval_count": 64,
"prompt_eval_duration": 420000000,
"eval_count": 48,
"eval_duration": 520000000
}
}

View File

@@ -0,0 +1,20 @@
{
"model": "llama3:8b-cloud",
"created_at": "2025-10-23T12:05:00Z",
"message": {
"role": "assistant",
"content": "Let me check the latest Rust updates.",
"tool_calls": [
{
"function": {
"name": "web_search",
"arguments": {
"query": "latest Rust release",
"max_results": 5
}
}
}
]
},
"done": false
}

View File

@@ -0,0 +1,18 @@
{
"model": "local-mini",
"created_at": "2025-10-23T12:00:00Z",
"message": {
"role": "assistant",
"content": "Local response complete.",
"tool_calls": []
},
"done": true,
"final_data": {
"total_duration": 1200000000,
"load_duration": 50000000,
"prompt_eval_count": 24,
"prompt_eval_duration": 320000000,
"eval_count": 12,
"eval_duration": 480000000
}
}

View File

@@ -0,0 +1,28 @@
{
"models": [
{
"name": "local-mini",
"size": 1048576,
"digest": "local-digest",
"modified_at": "2025-10-23T00:00:00Z",
"details": {
"format": "gguf",
"family": "llama3",
"parameter_size": "3B",
"quantization_level": "Q4"
}
},
{
"name": "llama3:8b-cloud",
"size": 8589934592,
"digest": "cloud-digest",
"modified_at": "2025-10-23T00:05:00Z",
"details": {
"format": "gguf",
"family": "llama3",
"parameter_size": "8B",
"quantization_level": "Q4"
}
}
]
}

View File

@@ -0,0 +1,385 @@
use std::sync::Arc;
use owlen_core::types::{ChatParameters, Role};
use owlen_core::{
Config, Provider,
providers::OllamaProvider,
session::{SessionController, SessionOutcome},
storage::StorageManager,
ui::NoOpUiController,
};
use serde_json::{Value, json};
use tempfile::{TempDir, tempdir};
use wiremock::{
Match, Mock, MockServer, Request, ResponseTemplate,
matchers::{header, method, path},
};
#[derive(Clone, Copy)]
struct BodySubstringMatcher {
needle: &'static str,
should_contain: bool,
}
impl BodySubstringMatcher {
const fn contains(needle: &'static str) -> Self {
Self {
needle,
should_contain: true,
}
}
const fn not_contains(needle: &'static str) -> Self {
Self {
needle,
should_contain: false,
}
}
}
impl Match for BodySubstringMatcher {
fn matches(&self, request: &Request) -> bool {
let body_str = std::str::from_utf8(&request.body).unwrap_or_default();
body_str.contains(self.needle) == self.should_contain
}
}
fn load_fixture(name: &str) -> Value {
match name {
"ollama_tags" => serde_json::from_str(include_str!("fixtures/ollama_tags.json"))
.expect("valid tags fixture"),
"ollama_local_completion" => {
serde_json::from_str(include_str!("fixtures/ollama_local_completion.json"))
.expect("valid local completion fixture")
}
"ollama_cloud_tool_call" => {
serde_json::from_str(include_str!("fixtures/ollama_cloud_tool_call.json"))
.expect("valid cloud tool call fixture")
}
"ollama_cloud_final" => {
serde_json::from_str(include_str!("fixtures/ollama_cloud_final.json"))
.expect("valid cloud final fixture")
}
other => panic!("unknown fixture '{other}'"),
}
}
async fn create_session(
provider: Arc<dyn Provider>,
config: Config,
) -> (SessionController, TempDir) {
let temp_dir = tempdir().expect("temp dir");
let storage_path = temp_dir.path().join("owlen-tests.db");
let storage = Arc::new(
StorageManager::with_database_path(storage_path)
.await
.expect("storage manager"),
);
let ui = Arc::new(NoOpUiController);
let session = SessionController::new(provider, config, storage, ui, false, None)
.await
.expect("session controller");
(session, temp_dir)
}
fn configure_local(base_url: &str) -> Config {
let mut config = Config::default();
config.general.default_provider = "ollama_local".into();
config.general.default_model = Some("local-mini".into());
config.general.enable_streaming = false;
config.privacy.encrypt_local_data = false;
config.privacy.require_consent_per_session = false;
if let Some(local) = config.providers.get_mut("ollama_local") {
local.enabled = true;
local.base_url = Some(base_url.to_string());
}
config
}
fn configure_cloud(base_url: &str) -> Config {
let mut config = Config::default();
config.general.default_provider = "ollama_cloud".into();
config.general.default_model = Some("llama3:8b-cloud".into());
config.general.enable_streaming = false;
config.privacy.enable_remote_search = true;
config.privacy.encrypt_local_data = false;
config.privacy.require_consent_per_session = false;
config.tools.web_search.enabled = true;
if let Some(cloud) = config.providers.get_mut("ollama_cloud") {
cloud.enabled = true;
cloud.base_url = Some(base_url.to_string());
cloud.api_key = Some("test-key".into());
cloud.extra.insert(
"web_search_endpoint".into(),
Value::String("/v1/web/search".into()),
);
}
config
}
#[tokio::test(flavor = "multi_thread")]
async fn local_provider_happy_path_records_usage() {
let server = MockServer::start().await;
let tags = load_fixture("ollama_tags");
let completion = load_fixture("ollama_local_completion");
let base_url = server.uri();
let tags_template = ResponseTemplate::new(200).set_body_json(tags);
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(tags_template.clone())
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(200).set_body_json(completion))
.mount(&server)
.await;
let config = configure_local(&base_url);
let provider: Arc<dyn Provider> =
Arc::new(OllamaProvider::new(base_url.clone()).expect("local provider"));
let (mut session, _tmp) = create_session(provider, config).await;
let outcome = session
.send_message(
"Summarise the local status.".to_string(),
ChatParameters::default(),
)
.await
.expect("local completion");
let response = match outcome {
SessionOutcome::Complete(response) => response,
_ => panic!("expected complete outcome"),
};
assert_eq!(response.message.content, "Local response complete.");
let snapshot = session
.current_usage_snapshot()
.await
.expect("usage snapshot");
assert_eq!(snapshot.provider, "ollama_local");
assert_eq!(snapshot.hourly.total_tokens, 36);
assert_eq!(snapshot.weekly.total_tokens, 36);
}
#[tokio::test(flavor = "multi_thread")]
async fn cloud_tool_call_flows_through_web_search() {
let server = MockServer::start().await;
let tags = load_fixture("ollama_tags");
let tool_call = load_fixture("ollama_cloud_tool_call");
let final_chunk = load_fixture("ollama_cloud_final");
let base_url = server.uri();
let tags_template = ResponseTemplate::new(200).set_body_json(tags);
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(tags_template.clone())
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.and(BodySubstringMatcher::not_contains("\"role\":\"tool\""))
.respond_with(ResponseTemplate::new(200).set_body_json(tool_call))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.and(BodySubstringMatcher::contains("\"role\":\"tool\""))
.respond_with(ResponseTemplate::new(200).set_body_json(final_chunk))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/web/search"))
.and(header("authorization", "Bearer test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"results": [
{
"title": "Rust 1.85 Released",
"url": "https://blog.rust-lang.org/2025/10/23/Rust-1.85.html",
"snippet": "Rust 1.85 lands with incremental compilation improvements."
}
]
})))
.expect(1)
.mount(&server)
.await;
let config = configure_cloud(&base_url);
let cloud_cfg = config
.providers
.get("ollama_cloud")
.expect("cloud provider config")
.clone();
let provider: Arc<dyn Provider> = Arc::new(
OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general))
.expect("cloud provider"),
);
let (mut session, _tmp) = create_session(provider, config).await;
session.grant_consent(
"web_search",
vec!["network".into()],
vec![format!("{}/v1/web/search", base_url)],
);
let outcome = session
.send_message(
"What is new in Rust today?".to_string(),
ChatParameters::default(),
)
.await
.expect("cloud completion");
let response = match outcome {
SessionOutcome::Complete(response) => response,
_ => panic!("expected complete outcome"),
};
assert_eq!(
response.message.content,
"Rust 1.85 shipped today. Summarising the highlights now."
);
let convo = session.conversation();
let tool_messages: Vec<_> = convo
.messages
.iter()
.filter(|msg| msg.role == Role::Tool)
.collect();
assert_eq!(tool_messages.len(), 1);
assert!(
tool_messages[0].content.contains("Rust 1.85 Released"),
"tool response should include search result"
);
let snapshot = session
.current_usage_snapshot()
.await
.expect("usage snapshot");
assert_eq!(snapshot.provider, "ollama_cloud");
assert_eq!(snapshot.hourly.total_tokens, 112); // 64 prompt + 48 completion
assert_eq!(snapshot.weekly.total_tokens, 112);
}
#[tokio::test(flavor = "multi_thread")]
async fn cloud_unauthorized_degrades_without_usage() {
let server = MockServer::start().await;
let tags = load_fixture("ollama_tags");
let tags_template = ResponseTemplate::new(200).set_body_json(tags);
let base_url = server.uri();
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(tags_template.clone())
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": "unauthorized"
})))
.mount(&server)
.await;
let config = configure_cloud(&base_url);
let cloud_cfg = config
.providers
.get("ollama_cloud")
.expect("cloud provider config")
.clone();
let provider: Arc<dyn Provider> = Arc::new(
OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general))
.expect("cloud provider"),
);
let (mut session, _tmp) = create_session(provider, config).await;
let err_text = match session
.send_message("Switch to cloud".to_string(), ChatParameters::default())
.await
{
Ok(_) => panic!("expected unauthorized error, but request succeeded"),
Err(err) => err.to_string(),
};
assert!(
err_text.contains("unauthorized") || err_text.contains("API key"),
"error should surface unauthorized detail, got: {err_text}"
);
let snapshot = session
.current_usage_snapshot()
.await
.expect("usage snapshot");
assert_eq!(snapshot.hourly.total_tokens, 0);
assert_eq!(snapshot.weekly.total_tokens, 0);
}
#[tokio::test(flavor = "multi_thread")]
async fn cloud_rate_limit_returns_error_without_usage() {
let server = MockServer::start().await;
let tags = load_fixture("ollama_tags");
let tags_template = ResponseTemplate::new(200).set_body_json(tags);
let base_url = server.uri();
Mock::given(method("GET"))
.and(path("/api/tags"))
.respond_with(tags_template.clone())
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/chat"))
.respond_with(ResponseTemplate::new(429).set_body_json(json!({
"error": "too many requests"
})))
.mount(&server)
.await;
let config = configure_cloud(&base_url);
let cloud_cfg = config
.providers
.get("ollama_cloud")
.expect("cloud provider config")
.clone();
let provider: Arc<dyn Provider> = Arc::new(
OllamaProvider::from_config("ollama_cloud", &cloud_cfg, Some(&config.general))
.expect("cloud provider"),
);
let (mut session, _tmp) = create_session(provider, config).await;
let err_text = match session
.send_message("Hit rate limit".to_string(), ChatParameters::default())
.await
{
Ok(_) => panic!("expected rate-limit error, but request succeeded"),
Err(err) => err.to_string(),
};
assert!(
err_text.contains("rate limited") || err_text.contains("429"),
"error should mention rate limiting, got: {err_text}"
);
let snapshot = session
.current_usage_snapshot()
.await
.expect("usage snapshot");
assert_eq!(snapshot.hourly.total_tokens, 0);
assert_eq!(snapshot.weekly.total_tokens, 0);
}