//! HTTP integration tests against the mock backend: no browser, no network. use std::sync::Arc; use std::time::Duration; use arc_swap::ArcSwap; use axum::body::Body; use axum::http::{HeaderMap, Request, StatusCode}; use axum::Router; use http_body_util::BodyExt; use llm_bridge::config::{CliOverrides, GlobalConfig, ScreenshotMode}; use llm_bridge::conversation::ThreadStore; use llm_bridge::paths::Layout; use llm_bridge::providers::{AccountInfo, ChatBackend, MockBackend}; use llm_bridge::server::{router, AppState, LogBuffer}; use serde_json::{json, Value}; use tower::ServiceExt; struct Harness { app: Router, store: Arc, state: AppState, } /// The two accounts of the mock ChatGPT provider. fn mock_accounts() -> Vec { vec![ AccountInfo { id: "perso".to_string(), email: Some("bruno.charest@gmail.com".to_string()), label: None, default: true, profile: "profiles/chatgpt/accounts/perso/user-data-dir".to_string(), }, AccountInfo { id: "pro".to_string(), email: Some("brunocharest.bc@gmail.com".to_string()), label: Some("Work".to_string()), default: false, profile: "profiles/chatgpt/accounts/pro/user-data-dir".to_string(), }, ] } fn mock_backend(accounts: bool) -> MockBackend { let backend = MockBackend::new( vec![("chatgpt", "gpt-4o"), ("claude", "claude-sonnet-4")], Duration::from_millis(1), ); if accounts { backend.with_accounts("chatgpt", mock_accounts()) } else { backend } } fn harness_with(config: GlobalConfig) -> Harness { harness_with_backend(config, mock_backend(false)) } fn harness_with_backend(config: GlobalConfig, backend: MockBackend) -> Harness { let backend: Arc = Arc::new(backend); let store = Arc::new(ThreadStore::in_memory()); let layout = Layout::rooted_at(std::env::temp_dir().join("llm-gateway-tests")); let state = AppState::new( backend, Arc::clone(&store), layout, Arc::new(ArcSwap::from_pointee(config)), CliOverrides::default(), LogBuffer::new(32), ); Harness { app: router(state.clone()), store, state, } } fn harness() -> Harness { harness_with(GlobalConfig::default()) } /// A harness whose ChatGPT provider declares two accounts. fn harness_with_accounts(config: GlobalConfig) -> Harness { harness_with_backend(config, mock_backend(true)) } async fn send(app: &Router, request: Request) -> (StatusCode, HeaderMap, String) { let response = app .clone() .oneshot(request) .await .expect("router call failed"); let status = response.status(); let headers = response.headers().clone(); let bytes = response .into_body() .collect() .await .expect("body") .to_bytes(); (status, headers, String::from_utf8_lossy(&bytes).to_string()) } async fn get_json(app: &Router, path: &str) -> (StatusCode, Value) { let request = Request::builder().uri(path).body(Body::empty()).unwrap(); let (status, _, body) = send(app, request).await; (status, serde_json::from_str(&body).unwrap_or(Value::Null)) } fn post_chat(body: Value) -> Request { Request::builder() .method("POST") .uri("/v1/chat/completions") .header("content-type", "application/json") .body(Body::from(body.to_string())) .unwrap() } #[tokio::test] async fn health_and_models_describe_every_provider() { let harness = harness(); let (status, health) = get_json(&harness.app, "/health").await; assert_eq!(status, StatusCode::OK); assert_eq!(health["status"], "ok"); assert_eq!(health["providers"].as_array().unwrap().len(), 2); let (status, models) = get_json(&harness.app, "/v1/models").await; assert_eq!(status, StatusCode::OK); assert_eq!(models["object"], "list"); let ids: Vec = models["data"] .as_array() .unwrap() .iter() .map(|entry| entry["id"].as_str().unwrap().to_string()) .collect(); assert!(ids.contains(&"chatgpt".to_string())); assert!(ids.contains(&"chatgpt/gpt-4o".to_string())); assert!(ids.contains(&"claude/claude-sonnet-4".to_string())); } #[tokio::test] async fn a_chat_completion_returns_the_openai_shape_and_a_conversation_id() { let harness = harness(); let (status, headers, body) = send( &harness.app, post_chat(json!({ "model": "chatgpt", "messages": [{"role": "user", "content": "hello"}], })), ) .await; assert_eq!(status, StatusCode::OK); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["object"], "chat.completion"); assert_eq!(value["model"], "chatgpt"); assert_eq!(value["choices"][0]["message"]["role"], "assistant"); assert_eq!(value["choices"][0]["finish_reason"], "stop"); let content = value["choices"][0]["message"]["content"].as_str().unwrap(); assert!(!content.is_empty()); assert!(value["usage"]["total_tokens"].as_u64().unwrap() >= 1); assert!(value["id"].as_str().unwrap().starts_with("chatcmpl-")); let conversation = headers .get("x-conversation-id") .unwrap() .to_str() .unwrap() .to_string(); assert!(conversation.starts_with("fp-"), "{conversation}"); assert!(headers.get("x-trace-id").is_some()); // The thread mapping was persisted for the next turn. let record = harness .store .get(&conversation) .await .expect("thread recorded"); assert_eq!(record.provider, "chatgpt"); assert!(record.web_url.is_some()); } #[tokio::test] async fn the_same_conversation_is_reused_when_the_header_is_repeated() { let harness = harness(); let request = |text: &str| { post_chat(json!({ "model": "chatgpt", "messages": [ {"role": "user", "content": "opening"}, {"role": "assistant", "content": "answer"}, {"role": "user", "content": text}, ], })) }; let (_, headers, _) = send(&harness.app, request("second")).await; let conversation = headers .get("x-conversation-id") .unwrap() .to_str() .unwrap() .to_string(); let (status, _, _) = send(&harness.app, request("third")).await; assert_eq!(status, StatusCode::OK); let record = harness.store.get(&conversation).await.unwrap(); assert_eq!( record.turns, 2, "the second call must reuse the stored thread" ); } #[tokio::test] async fn streaming_emits_sse_chunks_and_terminates_with_done() { let harness = harness(); let (status, headers, body) = send( &harness.app, post_chat(json!({ "model": "chatgpt", "messages": [{"role": "user", "content": "hello"}], "stream": true, })), ) .await; assert_eq!(status, StatusCode::OK); let content_type = headers.get("content-type").unwrap().to_str().unwrap(); assert!( content_type.starts_with("text/event-stream"), "{content_type}" ); assert!(body.contains("chat.completion.chunk"), "{body}"); assert!(body.contains(r#""role":"assistant""#), "{body}"); assert!(body.contains("data: [DONE]"), "{body}"); let deltas: String = body .lines() .filter_map(|line| line.strip_prefix("data: ")) .filter(|payload| *payload != "[DONE]") .filter_map(|payload| serde_json::from_str::(payload).ok()) .filter_map(|chunk| { chunk["choices"][0]["delta"]["content"] .as_str() .map(str::to_string) }) .collect(); assert!(!deltas.is_empty()); assert!(deltas.contains("mock backend"), "{deltas}"); } #[tokio::test] async fn the_conversation_header_wins_over_the_fingerprint() { let harness = harness(); let request = Request::builder() .method("POST") .uri("/v1/chat/completions") .header("content-type", "application/json") .header("x-conversation-id", "client-thread-42") .body(Body::from( json!({"model": "chatgpt", "messages": [{"role": "user", "content": "hi"}]}) .to_string(), )) .unwrap(); let (status, headers, _) = send(&harness.app, request).await; assert_eq!(status, StatusCode::OK); assert_eq!( headers.get("x-conversation-id").unwrap(), "client-thread-42" ); assert!(harness.store.get("client-thread-42").await.is_some()); } #[tokio::test] async fn unsupported_parameters_are_rejected_with_a_clear_error() { let harness = harness(); let (status, _, body) = send( &harness.app, post_chat(json!({ "model": "chatgpt", "messages": [{"role": "user", "content": "hi"}], "tools": [{"type": "function", "function": {"name": "f"}}], })), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["error"]["code"], "unsupported_parameter"); assert_eq!(value["error"]["param"], "tools"); assert_eq!(value["error"]["type"], "invalid_request_error"); // n = 0 is a client mistake, unlike n > 1 which is now supported. let (status, _, body) = send( &harness.app, post_chat(json!({ "model": "chatgpt", "messages": [{"role": "user", "content": "hi"}], "n": 0, })), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["error"]["param"], "n"); } #[tokio::test] async fn several_completions_come_back_as_several_choices() { let harness = harness(); let (status, _, body) = send( &harness.app, post_chat(json!({ "model": "chatgpt", "messages": [{"role": "user", "content": "hello"}], "n": 3, })), ) .await; assert_eq!(status, StatusCode::OK); let value: Value = serde_json::from_str(&body).unwrap(); let choices = value["choices"].as_array().unwrap(); assert_eq!(choices.len(), 3); for (index, choice) in choices.iter().enumerate() { assert_eq!(choice["index"], index as u64); assert_eq!(choice["message"]["role"], "assistant"); assert_eq!(choice["finish_reason"], "stop"); assert!(!choice["message"]["content"].as_str().unwrap().is_empty()); } assert!(value["usage"]["total_tokens"].as_u64().unwrap() >= 1); let warnings = value["x_gateway_warnings"].as_array().unwrap(); assert!( warnings.iter().any(|w| w .as_str() .unwrap() .contains("independent web conversations")), "{warnings:?}" ); } #[tokio::test] async fn n_is_clamped_to_the_configured_maximum() { let mut config = GlobalConfig::default(); config.capture.max_variants = 2; let harness = harness_with(config); let (status, _, body) = send( &harness.app, post_chat(json!({ "model": "chatgpt", "messages": [{"role": "user", "content": "hello"}], "n": 9, })), ) .await; assert_eq!(status, StatusCode::OK); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["choices"].as_array().unwrap().len(), 2); let warnings = value["x_gateway_warnings"].as_array().unwrap(); assert!( warnings .iter() .any(|w| w.as_str().unwrap().contains("clamped to 2")), "{warnings:?}" ); } #[tokio::test] async fn streamed_choices_are_tagged_with_their_index() { let harness = harness(); let (status, _, body) = send( &harness.app, post_chat(json!({ "model": "chatgpt", "messages": [{"role": "user", "content": "hello"}], "stream": true, "n": 2, })), ) .await; assert_eq!(status, StatusCode::OK); let chunks: Vec = body .lines() .filter_map(|line| line.strip_prefix("data: ")) .filter(|payload| *payload != "[DONE]") .filter_map(|payload| serde_json::from_str::(payload).ok()) .collect(); assert!(body.contains("data: [DONE]"), "{body}"); let indexes: Vec = chunks .iter() .flat_map(|chunk| chunk["choices"].as_array().cloned().unwrap_or_default()) .filter_map(|choice| choice["index"].as_u64()) .collect(); assert!(indexes.contains(&0), "{indexes:?}"); assert!(indexes.contains(&1), "{indexes:?}"); let stops = chunks .iter() .flat_map(|chunk| chunk["choices"].as_array().cloned().unwrap_or_default()) .filter(|choice| choice["finish_reason"] == "stop") .count(); assert_eq!(stops, 2, "each choice must finish: {body}"); } #[tokio::test] async fn an_account_can_be_selected_in_the_model_string() { let harness = harness_with_accounts(GlobalConfig::default()); // The declared accounts show up in /v1/models and /health. let (_, models) = get_json(&harness.app, "/v1/models").await; let ids: Vec = models["data"] .as_array() .unwrap() .iter() .map(|entry| entry["id"].as_str().unwrap().to_string()) .collect(); assert!(ids.contains(&"chatgpt@perso".to_string()), "{ids:?}"); assert!(ids.contains(&"chatgpt@pro".to_string()), "{ids:?}"); let (_, health) = get_json(&harness.app, "/health").await; let chatgpt = health["providers"] .as_array() .unwrap() .iter() .find(|provider| provider["name"] == "chatgpt") .unwrap(); assert_eq!(chatgpt["accounts"].as_array().unwrap().len(), 2); // By account id, and by e-mail address. for model in ["chatgpt@pro", "chatgpt@brunocharest.bc@gmail.com"] { let (status, _, body) = send( &harness.app, post_chat(json!({ "model": model, "messages": [{"role": "user", "content": "hello"}], })), ) .await; assert_eq!(status, StatusCode::OK, "{body}"); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["model"], model); } } #[tokio::test] async fn an_unknown_account_is_a_404_listing_the_known_ones() { let harness = harness_with_accounts(GlobalConfig::default()); let (status, _, body) = send( &harness.app, post_chat(json!({ "model": "chatgpt@nobody", "messages": [{"role": "user", "content": "hello"}], })), ) .await; assert_eq!(status, StatusCode::NOT_FOUND); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["error"]["code"], "account_not_found"); let message = value["error"]["message"].as_str().unwrap(); assert!(message.contains("no account 'nobody'"), "{message}"); assert!(message.contains("perso"), "{message}"); assert!( !message.contains("does not match any configured provider"), "the message must name the account problem: {message}" ); } #[tokio::test] async fn two_accounts_never_share_a_thread() { let harness = harness_with_accounts(GlobalConfig::default()); let request = |model: &str| { post_chat(json!({ "model": model, "messages": [{"role": "user", "content": "hello"}], })) }; let (_, first, _) = send(&harness.app, request("chatgpt@perso")).await; let (_, second, _) = send(&harness.app, request("chatgpt@pro")).await; let first = first .get("x-conversation-id") .unwrap() .to_str() .unwrap() .to_string(); let second = second .get("x-conversation-id") .unwrap() .to_str() .unwrap() .to_string(); assert_ne!(first, second, "the account is part of the thread identity"); } #[tokio::test] async fn an_unknown_model_is_a_404() { let harness = harness(); let (status, _, body) = send( &harness.app, post_chat(json!({ "model": "gemini", "messages": [{"role": "user", "content": "hi"}], })), ) .await; assert_eq!(status, StatusCode::NOT_FOUND); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["error"]["code"], "model_not_found"); assert!(value["error"]["message"] .as_str() .unwrap() .contains("chatgpt")); } #[tokio::test] async fn ignored_parameters_are_reported_in_the_response() { let harness = harness(); let (status, _, body) = send( &harness.app, post_chat(json!({ "model": "chatgpt", "messages": [{"role": "user", "content": "hi"}], "temperature": 0.1, "max_tokens": 10, })), ) .await; assert_eq!(status, StatusCode::OK); let value: Value = serde_json::from_str(&body).unwrap(); let warnings = value["x_gateway_warnings"].as_array().unwrap(); assert!(warnings .iter() .any(|w| w.as_str().unwrap().contains("temperature"))); assert!(warnings .iter() .any(|w| w.as_str().unwrap().contains("max_tokens"))); } #[tokio::test] async fn warnings_can_be_switched_off() { let mut config = GlobalConfig::default(); config.server.include_warnings = false; let harness = harness_with(config); let (status, _, body) = send( &harness.app, post_chat(json!({ "model": "chatgpt", "messages": [{"role": "user", "content": "hi"}], "top_p": 0.5, })), ) .await; assert_eq!(status, StatusCode::OK); let value: Value = serde_json::from_str(&body).unwrap(); assert!(value.get("x_gateway_warnings").is_none()); } #[tokio::test] async fn the_legacy_completions_endpoint_is_supported() { let harness = harness(); let request = Request::builder() .method("POST") .uri("/v1/completions") .header("content-type", "application/json") .body(Body::from( json!({"model": "chatgpt", "prompt": "say ok"}).to_string(), )) .unwrap(); let (status, headers, body) = send(&harness.app, request).await; assert_eq!(status, StatusCode::OK); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["object"], "text_completion"); assert!(!value["choices"][0]["text"].as_str().unwrap().is_empty()); assert!(headers.get("x-conversation-id").is_some()); let request = Request::builder() .method("POST") .uri("/v1/completions") .header("content-type", "application/json") .body(Body::from( json!({"model": "chatgpt", "prompt": "say ok", "stream": true}).to_string(), )) .unwrap(); let (status, _, body) = send(&harness.app, request).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert!(body.contains("unsupported_parameter")); } #[tokio::test] async fn provider_status_and_validation_are_exposed() { let harness = harness(); let (status, body) = get_json(&harness.app, "/v1/providers/chatgpt/status").await; assert_eq!(status, StatusCode::OK); assert_eq!(body["state"], "available"); assert_eq!(body["provider"], "chatgpt"); let request = Request::builder() .method("POST") .uri("/v1/providers/chatgpt/validate") .body(Body::empty()) .unwrap(); let (status, _, body) = send(&harness.app, request).await; assert_eq!(status, StatusCode::OK); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["status"], "ok"); assert!(!value["steps"].as_array().unwrap().is_empty()); let (status, body) = get_json(&harness.app, "/v1/providers/nope/status").await; assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(body["error"]["code"], "model_not_found"); } #[tokio::test] async fn provider_status_and_validation_can_name_an_account() { let harness = harness_with_accounts(GlobalConfig::default()); // A named account is probed, and the answer says which one. let (status, body) = get_json(&harness.app, "/v1/providers/chatgpt@pro/status").await; assert_eq!(status, StatusCode::OK); assert_eq!(body["state"], "available"); assert_eq!(body["provider"], "chatgpt@pro"); // The bare provider name means "its default account", and says so rather // than leaving the caller to guess which profile answered. let (status, body) = get_json(&harness.app, "/v1/providers/chatgpt/status").await; assert_eq!(status, StatusCode::OK); assert_eq!(body["provider"], "chatgpt@perso"); let (status, body) = post_account_validate(&harness, "chatgpt@pro").await; assert_eq!(status, StatusCode::OK); assert_eq!(body["ok"], true); assert_eq!(body["provider"], "chatgpt@pro"); // The status route keeps working for a provider that declares no account. let (status, body) = get_json(&harness.app, "/v1/providers/claude/status").await; assert_eq!(status, StatusCode::OK); assert_eq!(body["provider"], "claude"); } #[tokio::test] async fn an_unknown_account_is_a_404_on_the_status_routes() { let harness = harness_with_accounts(GlobalConfig::default()); let (status, body) = get_json(&harness.app, "/v1/providers/chatgpt@nobody/status").await; assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(body["error"]["code"], "account_not_found"); assert!(body["error"]["message"].as_str().unwrap().contains("perso")); let (status, body) = post_account_validate(&harness, "chatgpt@nobody").await; assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(body["error"]["code"], "account_not_found"); // A provider that declares no account refuses a named one instead of // silently probing its only profile. let (status, body) = get_json(&harness.app, "/v1/providers/claude@perso/status").await; assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(body["error"]["code"], "account_not_found"); } /// POST /v1/providers/{owner}/validate and parse the JSON body. async fn post_account_validate(harness: &Harness, owner: &str) -> (StatusCode, Value) { let request = Request::builder() .method("POST") .uri(format!("/v1/providers/{owner}/validate")) .body(Body::empty()) .unwrap(); let (status, _, body) = send(&harness.app, request).await; (status, serde_json::from_str(&body).unwrap_or(Value::Null)) } #[tokio::test] async fn reload_reports_the_configuration_diff() { let harness = harness(); let request = Request::builder() .method("POST") .uri("/v1/admin/reload") .body(Body::empty()) .unwrap(); let (status, _, body) = send(&harness.app, request).await; assert_eq!(status, StatusCode::OK); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["config"], "reloaded"); } #[tokio::test] async fn an_api_key_protects_the_v1_routes_only() { let mut config = GlobalConfig::default(); config.server.api_key = "s3cret".to_string(); let harness = harness_with(config); let (status, _, body) = send( &harness.app, post_chat(json!({ "model": "chatgpt", "messages": [{"role": "user", "content": "hi"}], })), ) .await; assert_eq!(status, StatusCode::UNAUTHORIZED); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["error"]["code"], "invalid_api_key"); let request = Request::builder() .method("POST") .uri("/v1/chat/completions") .header("content-type", "application/json") .header("authorization", "Bearer s3cret") .body(Body::from( json!({"model": "chatgpt", "messages": [{"role": "user", "content": "hi"}]}) .to_string(), )) .unwrap(); let (status, _, _) = send(&harness.app, request).await; assert_eq!(status, StatusCode::OK); // /health stays open so that monitors and the dashboard can reach it. let (status, _) = get_json(&harness.app, "/health").await; assert_eq!(status, StatusCode::OK); } #[tokio::test] async fn a_malformed_body_is_a_400_with_the_openai_error_shape() { let harness = harness(); let request = Request::builder() .method("POST") .uri("/v1/chat/completions") .header("content-type", "application/json") .body(Body::from("{not json")) .unwrap(); let (status, _, body) = send(&harness.app, request).await; assert_eq!(status, StatusCode::BAD_REQUEST); let value: Value = serde_json::from_str(&body).unwrap(); assert_eq!(value["error"]["code"], "invalid_request"); } #[tokio::test] async fn the_dashboard_is_served_and_exposes_metrics() { let harness = harness(); let request = Request::builder() .uri("/dashboard") .body(Body::empty()) .unwrap(); let (status, _, body) = send(&harness.app, request).await; assert_eq!(status, StatusCode::OK); assert!(body.contains("llm-gateway")); let (status, metrics) = get_json(&harness.app, "/metrics").await; assert_eq!(status, StatusCode::OK); assert!(metrics["requests_total"].is_number()); // The screenshot policy is only reachable through the configuration. let config = harness.state.config(); assert_eq!(config.debug.screenshots, ScreenshotMode::OnError); }