Files
openhuman/tests/channels_web_yuanbao_round22_raw_coverage_e2e.rs
T

423 lines
12 KiB
Rust

//! Round22 focused raw coverage for high-miss channel web/Yuanbao paths.
//!
//! All networked branches use loopback servers or in-memory debug seams.
use std::sync::{Arc, Mutex};
use axum::{
body::Bytes,
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::post,
Router,
};
use openhuman_core::openhuman::channels::providers::telegram::TelegramChannel;
use openhuman_core::openhuman::channels::providers::web::{
cancel_chat, start_chat, subscribe_web_channel_events, test_support as web_test_support,
ChatRequestMetadata,
};
use openhuman_core::openhuman::channels::providers::yuanbao::{
connection::test_support as yuanbao_connection_test_support,
cos::{cos_sign, get_cos_credentials, upload_to_cos, CosCredentials, CosSignInput},
YuanbaoConfig,
};
use openhuman_core::openhuman::channels::test_support::resolve_yuanbao_app_secret_for_test;
use openhuman_core::openhuman::channels::{Channel, SendMessage};
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::credentials::AuthService;
use serde_json::{json, Value};
use tempfile::tempdir;
use tokio::time::{timeout, Duration};
#[derive(Debug, Clone)]
struct RecordedRequest {
method: String,
headers: HeaderMap,
body: Value,
}
#[derive(Default)]
struct TelegramMockState {
requests: Mutex<Vec<RecordedRequest>>,
}
async fn telegram_handler(
Path((_token, method)): Path<(String, String)>,
State(state): State<Arc<TelegramMockState>>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let parsed = serde_json::from_slice::<Value>(&body).unwrap_or_else(|_| {
json!({
"raw": String::from_utf8_lossy(&body).to_string(),
})
});
state
.requests
.lock()
.expect("telegram requests")
.push(RecordedRequest {
method: method.clone(),
headers,
body: parsed.clone(),
});
match method.as_str() {
"setMessageReaction" => (
StatusCode::OK,
axum::Json(json!({"ok": true, "result": true})),
),
"sendAudio" => (
StatusCode::BAD_REQUEST,
axum::Json(json!({"ok": false, "description": "audio url rejected"})),
),
_ => (
StatusCode::OK,
axum::Json(json!({"ok": true, "result": {"message_id": 42}})),
),
}
}
async fn spawn_telegram_mock() -> (String, Arc<TelegramMockState>, tokio::task::JoinHandle<()>) {
let state = Arc::new(TelegramMockState::default());
let app = Router::new()
.route("/bot{token}/{method}", post(telegram_handler))
.with_state(Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind telegram mock");
let addr = listener.local_addr().expect("local addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(format!("http://{addr}"), state, handle)
}
#[derive(Default)]
struct CosMockState {
requests: Mutex<Vec<RecordedRequest>>,
}
async fn cos_handler(
State(state): State<Arc<CosMockState>>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let parsed = serde_json::from_slice::<Value>(&body).unwrap_or_else(|_| json!({}));
state
.requests
.lock()
.expect("cos requests")
.push(RecordedRequest {
method: "genUploadInfo".to_string(),
headers,
body: parsed,
});
(
StatusCode::OK,
axum::Json(json!({
"code": 0,
"data": {
"bucketName": "round22-bucket",
"region": "ap-shanghai",
"location": "dir with spaces/file name.png",
"encryptTmpSecretId": "AKID",
"encryptTmpSecretKey": "SECRET",
"encryptToken": "session-token",
"startTime": 1700000000u64,
"expiredTime": 1700003600u64,
"resourceUrl": "https://cdn.example/round22.png"
}
})),
)
}
async fn spawn_cos_mock() -> (String, Arc<CosMockState>, tokio::task::JoinHandle<()>) {
let state = Arc::new(CosMockState::default());
let app = Router::new()
.route(
"/api/resource/genUploadInfo",
post(cos_handler).put(cos_handler),
)
.with_state(Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind cos mock");
let addr = listener.local_addr().expect("local addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(format!("http://{addr}"), state, handle)
}
struct EnvGuard {
key: &'static str,
old: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: impl AsRef<str>) -> Self {
let old = std::env::var(key).ok();
unsafe {
std::env::set_var(key, value.as_ref());
}
Self { key, old }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match self.old.as_deref() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
fn isolated_config() -> (tempfile::TempDir, Config) {
let tmp = tempdir().expect("tempdir");
let mut config = Config::default();
config.workspace_dir = tmp.path().join("workspace");
config.config_path = tmp.path().join("config.toml");
std::fs::create_dir_all(&config.workspace_dir).expect("workspace");
(tmp, config)
}
#[tokio::test]
async fn web_start_chat_validation_forced_error_and_cancel_paths_are_structured() {
assert_eq!(
start_chat(
" ",
"thread",
"hello",
None,
None,
None,
None,
None,
ChatRequestMetadata::default()
)
.await
.unwrap_err(),
"client_id is required"
);
assert_eq!(
start_chat(
"client",
" ",
"hello",
None,
None,
None,
None,
None,
ChatRequestMetadata::default()
)
.await
.unwrap_err(),
"thread_id is required"
);
web_test_support::set_forced_run_chat_task_error_for_test(Some(
"All providers/models failed. Attempts: openhuman API error (503 Service Unavailable)",
))
.await;
let mut rx = subscribe_web_channel_events();
let request_id = start_chat(
"round22-client",
"round22-thread",
"Please respond through the forced error seam.",
Some(" ".to_string()),
Some(0.4),
None,
None,
None,
ChatRequestMetadata::default(),
)
.await
.expect("accepted");
let event = timeout(Duration::from_secs(10), async {
loop {
let event = rx.recv().await.expect("web event");
if event.event == "chat_error" && event.request_id == request_id {
break event;
}
}
})
.await
.expect("chat_error");
assert_eq!(event.error_type.as_deref(), Some("provider_error"));
assert_eq!(event.error_fallback_available, Some(false));
let cancelled = cancel_chat("round22-client", "round22-thread")
.await
.expect("cancel after completion is ok");
assert_eq!(cancelled, None);
web_test_support::set_forced_run_chat_task_error_for_test(None).await;
}
#[tokio::test]
async fn yuanbao_cos_credentials_signing_and_connection_debug_paths() {
let (cos_base, cos_state, cos_server) = spawn_cos_mock().await;
let http = reqwest::Client::new();
let creds = get_cos_credentials(
&http, &cos_base, "app-key", "", "token", "canary", "file.png",
)
.await
.expect("credentials");
assert_eq!(creds.bucket, "round22-bucket");
assert_eq!(creds.location, "dir with spaces/file name.png");
let requests = cos_state.requests.lock().expect("cos requests");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, "genUploadInfo");
assert_eq!(
requests[0]
.headers
.get("x-id")
.and_then(|h| h.to_str().ok()),
Some("app-key")
);
assert_eq!(
requests[0]
.headers
.get("x-route-env")
.and_then(|h| h.to_str().ok()),
Some("canary")
);
assert_eq!(requests[0].body["fileName"], "file.png");
drop(requests);
let signature = cos_sign(&CosSignInput {
method: "PUT",
path: "/dir%20with%20spaces/file%20name.png",
params: &[("Param", "value with spaces")],
headers: &[("Host", "round22-bucket.cos.accelerate.myqcloud.com")],
secret_id: "AKID",
secret_key: "SECRET",
start_time: 1_700_000_000,
expire_seconds: 300,
});
assert!(signature.contains("q-header-list=host"));
assert!(signature.contains("q-url-param-list=param"));
let err = upload_to_cos(
&http,
&CosCredentials::default(),
b"not uploaded",
"file.png",
String::new(),
)
.await
.unwrap_err();
assert!(format!("{err:?}").contains("credentials missing"));
assert_eq!(
yuanbao_connection_test_support::auth_response_success_connect_id_for_test()
.expect("auth response"),
"connect-123"
);
assert!(
yuanbao_connection_test_support::auth_response_rejects_status_for_test()
.contains("status=401")
);
let events =
yuanbao_connection_test_support::handle_binary_routes_builtin_and_push_frames_for_test()
.await;
assert_eq!(
events,
vec!["kickout:logged out", "push:incoming-message:push-1"]
);
cos_server.abort();
}
#[tokio::test]
async fn startup_yuanbao_secret_hydration_respects_matching_app_key() {
let (_tmp, config) = isolated_config();
let auth = AuthService::from_config(&config);
auth.store_provider_token(
"channel:yuanbao:api_key",
"default",
"",
[
("app_key".to_string(), "round22-key".to_string()),
("app_secret".to_string(), "round22-secret".to_string()),
]
.into_iter()
.collect(),
true,
)
.expect("store credentials");
let hydrated = resolve_yuanbao_app_secret_for_test(
YuanbaoConfig {
app_key: "round22-key".to_string(),
app_secret: String::new(),
..Default::default()
},
&config,
);
assert_eq!(hydrated.app_secret, "round22-secret");
let stale = resolve_yuanbao_app_secret_for_test(
YuanbaoConfig {
app_key: "other-key".to_string(),
app_secret: String::new(),
..Default::default()
},
&config,
);
assert_eq!(stale.app_secret, "");
}
#[tokio::test]
async fn telegram_send_reaction_attachment_and_media_url_paths_use_loopback_api() {
let (base, state, server) = spawn_telegram_mock().await;
let _base_guard = EnvGuard::set("OPENHUMAN_TELEGRAM_BOT_API_BASE", base);
let channel = TelegramChannel::new("TEST:TOKEN".to_string(), vec!["*".to_string()], false);
channel
.send(
&SendMessage::new(
"[REACTION:✅|321] Reply after reacting.\n[VIDEO:https://example.test/v.mp4]",
"chat-1:99",
)
.in_thread(Some("123".to_string())),
)
.await
.expect("reaction plus video url");
let audio_err = channel
.send(&SendMessage::new(
"[AUDIO:https://example.test/a.mp3]",
"chat-1",
))
.await
.unwrap_err();
assert!(audio_err.to_string().contains("sendAudio by URL failed"));
let requests = state.requests.lock().expect("telegram requests");
assert!(requests.iter().any(|request| {
request.method == "setMessageReaction"
&& request.body["message_id"] == 321
&& request.body["reaction"][0]["emoji"] == "✅"
}));
assert!(requests
.iter()
.any(|request| request.method == "sendMessage"
&& request.body["message_thread_id"] == "99"
&& request.body["reply_to_message_id"] == 123));
assert!(requests.iter().any(|request| request.method == "sendVideo"
&& request.body["video"] == "https://example.test/v.mp4"));
assert!(requests.iter().any(|request| request.method == "sendAudio"
&& request.body["audio"] == "https://example.test/a.mp3"));
server.abort();
}