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:
@@ -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"
|
||||||
|
|||||||
11
crates/owlen-core/tests/fixtures/README.md
vendored
Normal file
11
crates/owlen-core/tests/fixtures/README.md
vendored
Normal 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.
|
||||||
18
crates/owlen-core/tests/fixtures/ollama_cloud_final.json
vendored
Normal file
18
crates/owlen-core/tests/fixtures/ollama_cloud_final.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
20
crates/owlen-core/tests/fixtures/ollama_cloud_tool_call.json
vendored
Normal file
20
crates/owlen-core/tests/fixtures/ollama_cloud_tool_call.json
vendored
Normal 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
|
||||||
|
}
|
||||||
18
crates/owlen-core/tests/fixtures/ollama_local_completion.json
vendored
Normal file
18
crates/owlen-core/tests/fixtures/ollama_local_completion.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
28
crates/owlen-core/tests/fixtures/ollama_tags.json
vendored
Normal file
28
crates/owlen-core/tests/fixtures/ollama_tags.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
385
crates/owlen-core/tests/ollama_wiremock.rs
Normal file
385
crates/owlen-core/tests/ollama_wiremock.rs
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user