Files
openhuman/tests/channels_lark_email_dispatch_round21_raw_coverage_e2e.rs
T

274 lines
11 KiB
Rust

//! Round21 raw integration coverage for channels provider seams.
//!
//! Loopback servers and parser fixtures only: no real Discord, Lark, IMAP, or
//! SMTP traffic is performed.
use axum::{extract::Path, http::StatusCode, routing::get, Json, Router};
use openhuman_core::openhuman::channels::providers::discord::api::test_support as discord_support;
use openhuman_core::openhuman::channels::providers::email_channel::{
test_support as email_support, EmailChannel, EmailConfig,
};
use openhuman_core::openhuman::channels::providers::lark::test_support as lark_support;
use openhuman_core::openhuman::channels::test_support::{
run_dispatch_harness, DispatchHarnessOptions, TestMemoryEntry,
};
use openhuman_core::openhuman::channels::LarkChannel;
use reqwest::StatusCode as ReqwestStatusCode;
use serde_json::json;
use tokio_tungstenite::tungstenite::Message as WsMsg;
async fn spawn_mock(app: Router) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind mock");
let addr = listener.local_addr().expect("mock addr");
tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve mock");
});
format!("http://127.0.0.1:{}", addr.port())
}
#[test]
fn lark_parser_support_covers_post_mentions_placeholders_and_webhook_payloads() {
let rich_post = json!({
"fr_fr": {
"title": "Incident digest",
"content": [[
{"tag": "text", "text": "See "},
{"tag": "a", "href": "https://status.example.test/42"},
{"tag": "at", "user_id": "ou_alice"},
{"tag": "unknown", "text": "ignored"}
]]
}
})
.to_string();
let post_text = lark_support::parse_post_content_for_test(&rich_post).expect("post text");
assert!(post_text.contains("Incident digest"));
assert!(post_text.contains("https://status.example.test/42"));
assert!(post_text.contains("@ou_alice"));
assert!(lark_support::parse_post_content_for_test("{}").is_none());
assert_eq!(
lark_support::strip_at_placeholders_for_test("@_user_1 please review @_user_99 now"),
"please review now"
);
assert!(!lark_support::should_respond_in_group_for_test(&[]));
assert!(lark_support::should_respond_in_group_for_test(&[
json!({"name": "OpenHuman"})
]));
assert!(lark_support::should_refresh_last_recv_for_test(
&WsMsg::Binary(vec![1, 2, 3].into())
));
assert!(!lark_support::should_refresh_last_recv_for_test(
&WsMsg::Text("not heartbeat".into())
));
let channel = LarkChannel::new(
"app".into(),
"secret".into(),
"verify".into(),
None,
vec!["ou_allowed".into()],
);
let payload = json!({
"header": {"event_type": "im.message.receive_v1"},
"event": {
"sender": {"sender_id": {"open_id": "ou_allowed"}},
"message": {
"message_type": "post",
"content": rich_post,
"chat_id": "oc_round21",
"create_time": "1700000000123"
}
}
});
let messages = channel.parse_event_payload(&payload);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].channel, "lark");
assert_eq!(messages[0].reply_target, "oc_round21");
assert_eq!(messages[0].timestamp, 1_700_000_000);
assert!(messages[0].content.contains("Incident digest"));
}
#[test]
fn email_parser_support_covers_text_html_attachment_and_message_building() {
let text_raw = b"From: Alice <alice@example.com>\r\nSubject: Plain\r\nMessage-ID: <plain-1@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello from plain text.\r\n";
let parsed = email_support::parse_email_fixture(text_raw).expect("plain parse");
assert_eq!(parsed.sender, "alice@example.com");
assert_eq!(parsed.subject.as_deref(), Some("Plain"));
assert!(parsed.text.contains("Hello from plain text."));
let html_raw = b"From: Bob <bob@example.com>\r\nSubject: HTML\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<div>Hello <strong>HTML</strong> body</div>\r\n";
let parsed_html = email_support::parse_email_fixture(html_raw).expect("html parse");
assert_eq!(parsed_html.sender, "bob@example.com");
assert_eq!(parsed_html.text, "Hello HTML body");
let attachment_raw = b"From: Ops <ops@example.com>\r\nSubject: Attachment\r\nContent-Type: multipart/mixed; boundary=\"b\"\r\n\r\n--b\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Disposition: attachment; filename=\"note.txt\"\r\n\r\nattachment text body\r\n--b--\r\n";
let parsed_attachment =
email_support::parse_email_fixture(attachment_raw).expect("attachment parse");
assert!(parsed_attachment.text.contains("[Attachment: note.txt]"));
assert!(parsed_attachment.text.contains("attachment text body"));
let channel = EmailChannel::new(EmailConfig {
from_address: "bot@example.com".into(),
allowed_senders: vec!["@example.com".into(), "trusted.test".into()],
..Default::default()
});
assert!(channel.is_sender_allowed("ALICE@example.com"));
assert!(channel.is_sender_allowed("person@trusted.test"));
assert!(!channel.is_sender_allowed("person@untrusted.test"));
let message = channel
.build_plain_message("listener@example.com", "Round21", "coverage body")
.expect("build message");
let wire = String::from_utf8_lossy(&message.formatted()).to_string();
assert!(wire.contains("Subject: Round21"));
assert!(wire.contains("coverage body"));
}
#[tokio::test]
async fn discord_loopback_support_covers_lists_auth_errors_and_permission_overwrites() {
let guild_app = Router::new().route(
"/users/@me/guilds",
get(|| async {
Json(json!([
{"id": "g2", "name": "Guild Two", "icon": null},
{"id": "g1", "name": "Guild One", "icon": "hash"}
]))
}),
);
let base = spawn_mock(guild_app).await;
let guilds = discord_support::list_bot_guilds_at_base_for_test(&base, "token")
.await
.expect("guilds");
assert_eq!(guilds.len(), 2);
assert_eq!(guilds[1].icon.as_deref(), Some("hash"));
let channel_app = Router::new().route(
"/guilds/{guild_id}/channels",
get(|Path(guild_id): Path<String>| async move {
assert_eq!(guild_id, "g1");
Json(json!([
{"id": "voice", "name": "Voice", "type": 2, "position": 0, "parent_id": null},
{"id": "late", "name": "Late", "type": 0, "position": 4, "parent_id": "cat"},
{"id": "early", "name": "Early", "type": 0, "position": 1, "parent_id": null}
]))
}),
);
let base = spawn_mock(channel_app).await;
let channels = discord_support::list_guild_channels_at_base_for_test(&base, "token", "g1")
.await
.expect("channels");
assert_eq!(
channels.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
vec!["early", "late"]
);
let auth_error = discord_support::format_discord_http_error_for_test(
"list_guilds",
ReqwestStatusCode::UNAUTHORIZED,
r#"{"message":"401: Unauthorized"}"#,
);
let lower = auth_error.to_ascii_lowercase();
assert!(!lower.contains("401"));
assert!(!lower.contains("unauthorized"));
assert!(auth_error.contains("Settings"));
let permission_app = Router::new()
.route("/users/@me", get(|| async { Json(json!({"id": "bot-1"})) }))
.route(
"/guilds/{guild_id}/members/{member_id}",
get(
|Path((_guild_id, member_id)): Path<(String, String)>| async move {
assert_eq!(member_id, "bot-1");
Json(json!({"roles": ["role-send"], "user": {"id": "bot-1"}}))
},
),
)
.route(
"/guilds/{guild_id}/roles",
get(|Path(guild_id): Path<String>| async move {
Json(json!([
{"id": guild_id, "permissions": "1024"},
{"id": "role-send", "permissions": "2048"}
]))
}),
)
.route(
"/channels/{channel_id}",
get(|Path(channel_id): Path<String>| async move {
assert_eq!(channel_id, "c1");
Json(json!({
"permission_overwrites": [
{"id": "role-send", "type": 0, "allow": "65536", "deny": "2048"},
{"id": "bot-1", "type": 1, "allow": "2048", "deny": "0"}
]
}))
}),
);
let base = spawn_mock(permission_app).await;
let check =
discord_support::check_channel_permissions_at_base_for_test(&base, "token", "g1", "c1")
.await
.expect("permission check");
assert!(check.can_view_channel);
assert!(check.can_send_messages);
assert!(check.can_read_message_history);
assert!(check.missing_permissions.is_empty());
let failing_app = Router::new().route(
"/users/@me",
get(|| async { (StatusCode::BAD_GATEWAY, "discord unavailable") }),
);
let base = spawn_mock(failing_app).await;
let err =
discord_support::check_channel_permissions_at_base_for_test(&base, "token", "g1", "c1")
.await
.expect_err("me lookup fails")
.to_string();
assert!(err.contains("get_bot_user"));
assert!(err.contains("502"));
}
#[tokio::test]
async fn dispatch_harness_round21_covers_non_web_context_success_and_timeout() {
let observed = run_dispatch_harness(DispatchHarnessOptions {
channel_name: "lark".to_string(),
content: "thanks, can you summarize remembered channel state?".to_string(),
thread_ts: Some("lark-thread".to_string()),
supports_reactions: true,
memory_entries: vec![TestMemoryEntry {
key: "round21".to_string(),
content: "Lark messages include reply target context.".to_string(),
score: Some(0.95),
}],
response_text: Some("lark dispatch response".to_string()),
..Default::default()
})
.await;
assert_eq!(observed.handler_channel_name, "lark");
assert!(observed.handler_history_text.contains("[Channel context]"));
assert!(observed.handler_history_text.contains("[Memory context]"));
assert!(observed
.sends
.iter()
.any(|send| send.kind == "send" && send.content.starts_with("[REACTION:")));
assert!(observed
.sends
.iter()
.any(|send| send.content == "lark dispatch response"));
let timed_out = run_dispatch_harness(DispatchHarnessOptions {
channel_name: "email".to_string(),
content: "force timeout".to_string(),
handler_delay_ms: 1_200,
timeout_secs: 1,
..Default::default()
})
.await;
assert!(timed_out
.sends
.iter()
.any(|send| send.content.contains("Request timed out")));
}