Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
//! Black-box integration tests for the DingTalk plugin.
|
||||
//!
|
||||
//! Tests the DingtalkPlugin through the public ChannelPlugin trait interface
|
||||
//! and ChannelManager integration.
|
||||
//!
|
||||
//! Covers test-plan items: TP-3 (partial — invalid creds), TP-4, EP-5.
|
||||
//!
|
||||
//! NOTE: Tests requiring a live DingTalk API (TP-1, EP-1) are not included.
|
||||
//! The unit tests within the crate cover pure function logic (callback encoding/
|
||||
//! decoding, chatId encoding/decoding, message extraction, AI Card param
|
||||
//! building, stream frame parsing, etc.).
|
||||
|
||||
#[cfg(feature = "dingtalk")]
|
||||
mod dingtalk_tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::manager::{ChannelManager, EnableChannelSpec, PluginFactory};
|
||||
use nomifun_channel::plugin::ChannelPlugin;
|
||||
use nomifun_channel::plugins::dingtalk::DingtalkPlugin;
|
||||
use nomifun_channel::types::{PluginConfig, PluginCredentials, PluginStatus, PluginType};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// -- Test infrastructure ------------------------------------------------
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_encryption_key() -> [u8; 32] {
|
||||
[0x42u8; 32]
|
||||
}
|
||||
|
||||
async fn setup() -> (ChannelManager, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(MockBroadcaster::new());
|
||||
let (message_tx, _message_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, _confirm_rx) = mpsc::channel(16);
|
||||
|
||||
let manager = ChannelManager::new(
|
||||
repo.clone(),
|
||||
broadcaster.clone(),
|
||||
make_encryption_key(),
|
||||
message_tx,
|
||||
confirm_tx,
|
||||
);
|
||||
|
||||
std::mem::forget(db);
|
||||
|
||||
(manager, repo, broadcaster)
|
||||
}
|
||||
|
||||
fn dingtalk_factory() -> PluginFactory {
|
||||
Box::new(|pt| {
|
||||
if pt == PluginType::Dingtalk {
|
||||
Some(Box::new(DingtalkPlugin::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_dingtalk_config(client_id: Option<&str>, client_secret: Option<&str>) -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
client_id: client_id.map(String::from),
|
||||
client_secret: client_secret.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_dingtalk_config_value(client_id: Option<&str>, client_secret: Option<&str>) -> serde_json::Value {
|
||||
let mut creds = serde_json::Map::new();
|
||||
if let Some(id) = client_id {
|
||||
creds.insert("clientId".into(), serde_json::Value::String(id.into()));
|
||||
}
|
||||
if let Some(secret) = client_secret {
|
||||
creds.insert("clientSecret".into(), serde_json::Value::String(secret.into()));
|
||||
}
|
||||
serde_json::json!({
|
||||
"credentials": creds,
|
||||
"config": { "mode": "websocket" }
|
||||
})
|
||||
}
|
||||
|
||||
// -- Plugin construction ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dingtalk_plugin_initial_state() {
|
||||
let plugin = DingtalkPlugin::new();
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
assert!(plugin.bot_info().is_none());
|
||||
assert!(plugin.last_error().is_none());
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Dingtalk);
|
||||
assert_eq!(plugin.active_user_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dingtalk_plugin_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<DingtalkPlugin>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dingtalk_plugin_as_trait_object() {
|
||||
let plugin = DingtalkPlugin::new();
|
||||
let boxed: Box<dyn ChannelPlugin> = Box::new(plugin);
|
||||
assert_eq!(boxed.plugin_type(), PluginType::Dingtalk);
|
||||
assert_eq!(boxed.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
// -- Factory registration -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn factory_creates_dingtalk_plugin() {
|
||||
let factory = dingtalk_factory();
|
||||
let plugin = factory(PluginType::Dingtalk);
|
||||
assert!(plugin.is_some());
|
||||
let plugin = plugin.unwrap();
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Dingtalk);
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_returns_none_for_other_types() {
|
||||
let factory = dingtalk_factory();
|
||||
assert!(factory(PluginType::Telegram).is_none());
|
||||
assert!(factory(PluginType::Lark).is_none());
|
||||
assert!(factory(PluginType::Weixin).is_none());
|
||||
}
|
||||
|
||||
// -- TP-3: Invalid credentials (client_id + client_secret) ---------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_invalid_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config(Some("invalid_id"), Some("invalid_secret"));
|
||||
let result = manager.test_plugin("dingtalk", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Missing client_id ---------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_client_id_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config(None, Some("secret123"));
|
||||
let result = manager.test_plugin("dingtalk", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("client_id"),
|
||||
"Error should mention client_id: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Missing client_secret -----------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_client_secret_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config(Some("key_123"), None);
|
||||
let result = manager.test_plugin("dingtalk", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("client_secret"),
|
||||
"Error should mention client_secret: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Empty credentials ---------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_empty_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config(Some(""), Some(""));
|
||||
let result = manager.test_plugin("dingtalk", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- EP-5: Invalid plugin type -------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_invalid_plugin_type_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config_value(Some("key_123"), Some("secret"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("nonexistent"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Enable with invalid credentials -------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_plugin_invalid_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
|
||||
let config = make_dingtalk_config_value(Some("bad_id"), Some("bad_secret"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("dingtalk"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Disable without DB row ----------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_without_db_row_returns_error() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let result = manager.disable_plugin("dingtalk").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- PS-1: Empty plugin status -------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_plugin_status_empty() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let statuses = manager.get_plugin_status().await.unwrap();
|
||||
assert!(statuses.is_empty());
|
||||
}
|
||||
|
||||
// -- Restore with nothing stored -----------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_plugins_none_stored() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = dingtalk_factory();
|
||||
let result = manager.restore_plugins(&factory).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(manager.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// -- Plugin running check ------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_plugin_running_false_when_not_enabled() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
assert!(!manager.is_plugin_running("dingtalk"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use nomifun_channel::formatter::format_text_for_platform;
|
||||
use nomifun_channel::types::PluginType;
|
||||
|
||||
// ── Telegram: escape HTML, then convert markdown to HTML tags ────
|
||||
|
||||
#[test]
|
||||
fn telegram_bold_and_code() {
|
||||
let input = "**bold** and `code`";
|
||||
let result = format_text_for_platform(input, PluginType::Telegram);
|
||||
assert!(result.contains("<b>bold</b>"), "got: {result}");
|
||||
assert!(result.contains("<code>code</code>"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_escapes_raw_html() {
|
||||
let input = "<script>alert(1)</script>";
|
||||
let result = format_text_for_platform(input, PluginType::Telegram);
|
||||
assert!(!result.contains("<script>"), "got: {result}");
|
||||
assert!(result.contains("<script>"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_code_block() {
|
||||
let input = "```rust\nfn main() {}\n```";
|
||||
let result = format_text_for_platform(input, PluginType::Telegram);
|
||||
assert!(result.contains("<pre><code>"), "got: {result}");
|
||||
assert!(result.contains("fn main()"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_link() {
|
||||
let input = "[click](https://example.com)";
|
||||
let result = format_text_for_platform(input, PluginType::Telegram);
|
||||
assert!(
|
||||
result.contains(r#"<a href="https://example.com">click</a>"#),
|
||||
"got: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lark / DingTalk: HTML tags → markdown syntax ─────────────────
|
||||
|
||||
#[test]
|
||||
fn lark_bold_and_code() {
|
||||
let input = "<b>bold</b> and <code>code</code>";
|
||||
let result = format_text_for_platform(input, PluginType::Lark);
|
||||
assert!(result.contains("**bold**"), "got: {result}");
|
||||
assert!(result.contains("`code`"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_link_with_protocol_whitelist() {
|
||||
let input = r#"<a href="https://ok.com">safe</a> <a href="javascript:void(0)">evil</a>"#;
|
||||
let result = format_text_for_platform(input, PluginType::Lark);
|
||||
assert!(result.contains("[safe](https://ok.com)"), "got: {result}");
|
||||
assert!(!result.contains("javascript:"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_strips_unknown_tags() {
|
||||
let input = "<div><b>bold</b></div>";
|
||||
let result = format_text_for_platform(input, PluginType::Lark);
|
||||
assert!(result.contains("**bold**"), "got: {result}");
|
||||
assert!(!result.contains("<div>"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dingtalk_same_output_as_lark() {
|
||||
let input = "<b>bold</b> and <i>italic</i>";
|
||||
let lark = format_text_for_platform(input, PluginType::Lark);
|
||||
let ding = format_text_for_platform(input, PluginType::Dingtalk);
|
||||
assert_eq!(lark, ding);
|
||||
}
|
||||
|
||||
// ── WeChat: strip all HTML ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn weixin_strips_all_html() {
|
||||
let input = "<b>bold</b> and <a href=\"url\">link</a>";
|
||||
let result = format_text_for_platform(input, PluginType::Weixin);
|
||||
assert!(!result.contains('<'), "got: {result}");
|
||||
assert!(!result.contains('>'), "got: {result}");
|
||||
assert!(result.contains("bold"), "got: {result}");
|
||||
assert!(result.contains("link"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weixin_decodes_entities() {
|
||||
let input = "& <tag>";
|
||||
let result = format_text_for_platform(input, PluginType::Weixin);
|
||||
assert_eq!(result.trim(), "& tag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weixin_nested_tags() {
|
||||
let input = "<scr<script>ipt>alert(1)</scr</script>ipt>";
|
||||
let result = format_text_for_platform(input, PluginType::Weixin);
|
||||
assert!(!result.contains('<'), "got: {result}");
|
||||
}
|
||||
|
||||
// ── Fallback: escape HTML ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn fallback_escapes_html() {
|
||||
let input = "<b>bold</b>";
|
||||
let result = format_text_for_platform(input, PluginType::Slack);
|
||||
assert!(result.contains("<b>"), "got: {result}");
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Black-box integration tests for the Lark (Feishu) plugin.
|
||||
//!
|
||||
//! Tests the LarkPlugin through the public ChannelPlugin trait interface
|
||||
//! and ChannelManager integration.
|
||||
//!
|
||||
//! Covers test-plan items: TP-3 (partial — invalid creds), TP-4, EP-5.
|
||||
//!
|
||||
//! NOTE: Tests requiring a live Lark API (TP-1, EP-1) are not included.
|
||||
//! The unit tests within the crate cover pure function logic (event parsing,
|
||||
//! card building, deduplication, callback encoding/decoding, etc.).
|
||||
|
||||
#[cfg(feature = "lark")]
|
||||
mod lark_tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::manager::{ChannelManager, EnableChannelSpec, PluginFactory};
|
||||
use nomifun_channel::plugin::ChannelPlugin;
|
||||
use nomifun_channel::plugins::lark::LarkPlugin;
|
||||
use nomifun_channel::types::{PluginConfig, PluginCredentials, PluginStatus, PluginType};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// -- Test infrastructure ------------------------------------------------
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_encryption_key() -> [u8; 32] {
|
||||
[0x42u8; 32]
|
||||
}
|
||||
|
||||
async fn setup() -> (ChannelManager, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(MockBroadcaster::new());
|
||||
let (message_tx, _message_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, _confirm_rx) = mpsc::channel(16);
|
||||
|
||||
let manager = ChannelManager::new(
|
||||
repo.clone(),
|
||||
broadcaster.clone(),
|
||||
make_encryption_key(),
|
||||
message_tx,
|
||||
confirm_tx,
|
||||
);
|
||||
|
||||
std::mem::forget(db);
|
||||
|
||||
(manager, repo, broadcaster)
|
||||
}
|
||||
|
||||
fn lark_factory() -> PluginFactory {
|
||||
Box::new(|pt| {
|
||||
if pt == PluginType::Lark {
|
||||
Some(Box::new(LarkPlugin::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_lark_config(app_id: Option<&str>, app_secret: Option<&str>) -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
app_id: app_id.map(String::from),
|
||||
app_secret: app_secret.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_lark_config_value(app_id: Option<&str>, app_secret: Option<&str>) -> serde_json::Value {
|
||||
let mut creds = serde_json::Map::new();
|
||||
if let Some(id) = app_id {
|
||||
creds.insert("appId".into(), serde_json::Value::String(id.into()));
|
||||
}
|
||||
if let Some(secret) = app_secret {
|
||||
creds.insert("appSecret".into(), serde_json::Value::String(secret.into()));
|
||||
}
|
||||
serde_json::json!({
|
||||
"credentials": creds,
|
||||
"config": { "mode": "websocket" }
|
||||
})
|
||||
}
|
||||
|
||||
// -- Plugin construction ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn lark_plugin_initial_state() {
|
||||
let plugin = LarkPlugin::new();
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
assert!(plugin.bot_info().is_none());
|
||||
assert!(plugin.last_error().is_none());
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Lark);
|
||||
assert_eq!(plugin.active_user_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_plugin_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<LarkPlugin>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_plugin_as_trait_object() {
|
||||
let plugin = LarkPlugin::new();
|
||||
let boxed: Box<dyn ChannelPlugin> = Box::new(plugin);
|
||||
assert_eq!(boxed.plugin_type(), PluginType::Lark);
|
||||
assert_eq!(boxed.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
// -- Factory registration -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn factory_creates_lark_plugin() {
|
||||
let factory = lark_factory();
|
||||
let plugin = factory(PluginType::Lark);
|
||||
assert!(plugin.is_some());
|
||||
let plugin = plugin.unwrap();
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Lark);
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_returns_none_for_other_types() {
|
||||
let factory = lark_factory();
|
||||
assert!(factory(PluginType::Telegram).is_none());
|
||||
assert!(factory(PluginType::Dingtalk).is_none());
|
||||
assert!(factory(PluginType::Weixin).is_none());
|
||||
}
|
||||
|
||||
// -- TP-3: Invalid credentials (app_id + app_secret) --------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_invalid_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config(Some("invalid_app_id"), Some("invalid_secret"));
|
||||
let result = manager.test_plugin("lark", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Missing app_id -----------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_app_id_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config(None, Some("secret123"));
|
||||
let result = manager.test_plugin("lark", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("app_id"),
|
||||
"Error should mention app_id: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Missing app_secret -------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_app_secret_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config(Some("cli_123"), None);
|
||||
let result = manager.test_plugin("lark", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("app_secret"),
|
||||
"Error should mention app_secret: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Empty credentials --------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_empty_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config(Some(""), Some(""));
|
||||
let result = manager.test_plugin("lark", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- EP-5: Invalid plugin type ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_invalid_plugin_type_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config_value(Some("cli_123"), Some("secret"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("nonexistent"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Enable with invalid credentials ------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_plugin_invalid_credentials_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
|
||||
let config = make_lark_config_value(Some("bad_id"), Some("bad_secret"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("lark"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Disable without DB row ---------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_without_db_row_returns_error() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let result = manager.disable_plugin("lark").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- PS-1: Empty plugin status ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_plugin_status_empty() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let statuses = manager.get_plugin_status().await.unwrap();
|
||||
assert!(statuses.is_empty());
|
||||
}
|
||||
|
||||
// -- Restore with nothing stored ----------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_plugins_none_stored() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = lark_factory();
|
||||
let result = manager.restore_plugins(&factory).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(manager.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// -- Plugin running check -----------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_plugin_running_false_when_not_enabled() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
assert!(!manager.is_plugin_running("lark"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
//! Black-box integration tests for `ChannelManager`.
|
||||
//!
|
||||
//! Uses real SQLite (in-memory) and mock EventBroadcaster + MockPlugin.
|
||||
//! Covers test-plan items: PS-1..PS-3, EP-1..EP-5, DP-1..DP-4,
|
||||
//! TP-1..TP-5, CS-1..CS-2, WS-2.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::error::ChannelError;
|
||||
use nomifun_channel::manager::{ChannelManager, EnableChannelSpec, PluginFactory};
|
||||
use nomifun_channel::plugin::{ChannelPlugin, PluginCallbacks};
|
||||
use nomifun_channel::types::{
|
||||
BotInfo, OutgoingMessageType, PluginConfig, PluginCredentials, PluginStatus, PluginType, UnifiedOutgoingMessage,
|
||||
};
|
||||
use nomifun_common::decrypt_string;
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// ── Test infrastructure ─────────────────────────────────────────────
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
|
||||
let mut guard = self.events.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock plugin that tracks lifecycle calls.
|
||||
struct MockPlugin {
|
||||
status: PluginStatus,
|
||||
plugin_type: PluginType,
|
||||
bot_info: Option<BotInfo>,
|
||||
last_error: Option<String>,
|
||||
should_fail_init: bool,
|
||||
start_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl MockPlugin {
|
||||
fn new(plugin_type: PluginType) -> Self {
|
||||
Self {
|
||||
status: PluginStatus::Created,
|
||||
plugin_type,
|
||||
bot_info: None,
|
||||
last_error: None,
|
||||
should_fail_init: false,
|
||||
start_calls: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn failing(plugin_type: PluginType) -> Self {
|
||||
Self {
|
||||
should_fail_init: true,
|
||||
..Self::new(plugin_type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ChannelPlugin for MockPlugin {
|
||||
async fn initialize(&mut self, _config: PluginConfig, _callbacks: PluginCallbacks) -> Result<(), ChannelError> {
|
||||
if self.should_fail_init {
|
||||
self.status = PluginStatus::Error;
|
||||
self.last_error = Some("Mock init failure".into());
|
||||
return Err(ChannelError::ConnectionFailed("Mock init failure".into()));
|
||||
}
|
||||
self.status = PluginStatus::Initializing;
|
||||
self.bot_info = Some(BotInfo {
|
||||
id: "mock_bot".into(),
|
||||
username: Some("mock_bot_user".into()),
|
||||
display_name: "Mock Bot".into(),
|
||||
});
|
||||
self.status = PluginStatus::Ready;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start(&mut self) -> Result<(), ChannelError> {
|
||||
self.start_calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.status = PluginStatus::Starting;
|
||||
self.status = PluginStatus::Running;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&mut self) -> Result<(), ChannelError> {
|
||||
self.status = PluginStatus::Stopping;
|
||||
self.status = PluginStatus::Stopped;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_message(&self, _chat_id: &str, _message: UnifiedOutgoingMessage) -> Result<String, ChannelError> {
|
||||
Ok("mock_msg_id".into())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_chat_id: &str,
|
||||
_message_id: &str,
|
||||
_message: UnifiedOutgoingMessage,
|
||||
) -> Result<(), ChannelError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_user_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn bot_info(&self) -> Option<&BotInfo> {
|
||||
self.bot_info.as_ref()
|
||||
}
|
||||
|
||||
fn plugin_type(&self) -> PluginType {
|
||||
self.plugin_type
|
||||
}
|
||||
|
||||
fn status(&self) -> PluginStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_key() -> [u8; 32] {
|
||||
[0x42; 32]
|
||||
}
|
||||
|
||||
async fn setup() -> (ChannelManager, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let bc = Arc::new(MockBroadcaster::new());
|
||||
let (msg_tx, _msg_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, _confirm_rx) = mpsc::channel(16);
|
||||
let mgr = ChannelManager::new(repo.clone(), bc.clone(), test_key(), msg_tx, confirm_tx);
|
||||
// Keep db alive by leaking — test process exits anyway
|
||||
std::mem::forget(db);
|
||||
(mgr, repo, bc)
|
||||
}
|
||||
|
||||
fn make_factory() -> PluginFactory {
|
||||
Box::new(|pt| Some(Box::new(MockPlugin::new(pt))))
|
||||
}
|
||||
|
||||
fn make_failing_factory() -> PluginFactory {
|
||||
Box::new(|pt| Some(Box::new(MockPlugin::failing(pt))))
|
||||
}
|
||||
|
||||
fn make_no_impl_factory() -> PluginFactory {
|
||||
Box::new(|_pt| None)
|
||||
}
|
||||
|
||||
fn make_counting_factory() -> (PluginFactory, Arc<AtomicUsize>) {
|
||||
let start_calls = Arc::new(AtomicUsize::new(0));
|
||||
let captured = Arc::clone(&start_calls);
|
||||
let factory = Box::new(move |pt| {
|
||||
let mut plugin = MockPlugin::new(pt);
|
||||
plugin.start_calls = Arc::clone(&captured);
|
||||
Some(Box::new(plugin) as Box<dyn ChannelPlugin>)
|
||||
});
|
||||
(factory, start_calls)
|
||||
}
|
||||
|
||||
fn make_telegram_config() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"credentials": { "token": "bot:valid123" },
|
||||
"config": { "mode": "polling" }
|
||||
})
|
||||
}
|
||||
|
||||
fn make_lark_config() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"credentials": {
|
||||
"app_id": "cli_abc",
|
||||
"app_secret": "secret123"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_plugin_config() -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
token: Some("bot:valid123".into()),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_test_outgoing() -> UnifiedOutgoingMessage {
|
||||
UnifiedOutgoingMessage {
|
||||
message_type: OutgoingMessageType::Text,
|
||||
text: Some("hello".into()),
|
||||
parse_mode: None,
|
||||
buttons: None,
|
||||
keyboard: None,
|
||||
image_url: None,
|
||||
file_url: None,
|
||||
file_name: None,
|
||||
media_actions: None,
|
||||
reply_to_message_id: None,
|
||||
silent: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── PS-1: Get plugin status (no plugins) ──────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ps1_get_status_empty() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let statuses = mgr.get_plugin_status().await.unwrap();
|
||||
assert!(statuses.is_empty());
|
||||
}
|
||||
|
||||
// ── PS-2: Get plugin status (with plugins) ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ps2_get_status_with_plugins() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let statuses = mgr.get_plugin_status().await.unwrap();
|
||||
assert_eq!(statuses.len(), 1);
|
||||
assert_eq!(statuses[0].plugin_id, "telegram");
|
||||
assert_eq!(statuses[0].plugin_type, "telegram");
|
||||
assert_eq!(statuses[0].name, "Telegram Bot");
|
||||
assert!(statuses[0].enabled);
|
||||
assert_eq!(statuses[0].status.as_deref(), Some("running"));
|
||||
}
|
||||
|
||||
// ── EP-1: Enable Telegram plugin ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ep1_enable_telegram_plugin() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Plugin persisted in DB
|
||||
let row = repo.get_plugin("telegram").await.unwrap().unwrap();
|
||||
assert!(row.enabled);
|
||||
assert_eq!(row.r#type, "telegram");
|
||||
assert_eq!(row.name, "Telegram Bot");
|
||||
assert!(row.last_connected.is_some());
|
||||
|
||||
// Plugin is running in memory
|
||||
assert!(mgr.is_plugin_running("telegram"));
|
||||
assert_eq!(mgr.active_plugin_count(), 1);
|
||||
}
|
||||
|
||||
// ── EP-2: Re-enable updates config ────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ep2_re_enable_updates_config() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Re-enable with different config
|
||||
let new_config = serde_json::json!({
|
||||
"credentials": { "token": "bot:new_token_456" },
|
||||
"config": { "mode": "webhook", "webhook_url": "https://example.com" }
|
||||
});
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &new_config, &factory).await.unwrap();
|
||||
|
||||
// Still only one plugin
|
||||
assert_eq!(mgr.active_plugin_count(), 1);
|
||||
|
||||
// Config should be updated
|
||||
let row = repo.get_plugin("telegram").await.unwrap().unwrap();
|
||||
let decrypted = decrypt_string(&row.config, &test_key()).unwrap();
|
||||
let config: PluginConfig = serde_json::from_str(&decrypted).unwrap();
|
||||
assert_eq!(config.credentials.token.as_deref(), Some("bot:new_token_456"));
|
||||
}
|
||||
|
||||
// ── EP-5: Invalid plugin ID ──────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ep5_invalid_plugin_id() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
let err = mgr
|
||||
.enable_plugin(&EnableChannelSpec::legacy("nonexistent"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::InvalidPluginType(_)));
|
||||
}
|
||||
|
||||
// ── EP-3/EP-4: Missing required fields ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ep3_ep4_invalid_config_structure() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
// Missing credentials entirely
|
||||
let bad = serde_json::json!({ "wrong_key": "value" });
|
||||
let err = mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &bad, &factory).await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::InvalidConfig(_)));
|
||||
}
|
||||
|
||||
// ── DP-1: Disable enabled plugin ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dp1_disable_enabled_plugin() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
mgr.disable_plugin("telegram").await.unwrap();
|
||||
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
assert!(!mgr.is_plugin_running("telegram"));
|
||||
|
||||
let row = repo.get_plugin("telegram").await.unwrap().unwrap();
|
||||
assert!(!row.enabled);
|
||||
assert_eq!(row.status.as_deref(), Some("stopped"));
|
||||
}
|
||||
|
||||
// ── DP-2: Disable already disabled (idempotent) ──────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dp2_disable_already_disabled() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
mgr.disable_plugin("telegram").await.unwrap();
|
||||
|
||||
// Second disable should not error
|
||||
mgr.disable_plugin("telegram").await.unwrap();
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// ── TP-1: Test valid credentials returns bot username ─────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tp1_test_valid_credentials() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
let result = mgr
|
||||
.test_plugin("telegram", make_plugin_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.as_deref(), Some("mock_bot_user"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_initializes_without_starting_runtime() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let (factory, start_calls) = make_counting_factory();
|
||||
|
||||
let username = mgr
|
||||
.test_plugin("telegram", make_plugin_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(username.as_deref(), Some("mock_bot_user"));
|
||||
assert_eq!(
|
||||
start_calls.load(Ordering::SeqCst),
|
||||
0,
|
||||
"credential tests must not start long-running plugin runtime"
|
||||
);
|
||||
}
|
||||
|
||||
// ── TP-2: Test invalid credentials propagates error ───────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tp2_test_invalid_credentials() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_failing_factory();
|
||||
|
||||
let err = mgr.test_plugin("telegram", make_plugin_config(), &factory).await;
|
||||
assert!(err.is_err());
|
||||
}
|
||||
|
||||
// ── TP-4: Missing plugin ID ──────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tp4_test_invalid_plugin_type() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
let err = mgr
|
||||
.test_plugin("nonexistent", make_plugin_config(), &factory)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::InvalidPluginType(_)));
|
||||
}
|
||||
|
||||
// ── TP: Test does not persist ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tp_test_does_not_persist() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.test_plugin("telegram", make_plugin_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let plugins = repo.get_all_plugins().await.unwrap();
|
||||
assert!(plugins.is_empty());
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// ── CS-1: Credentials stored encrypted ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cs1_credentials_stored_encrypted() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = repo.get_plugin("telegram").await.unwrap().unwrap();
|
||||
|
||||
// Config should not contain plaintext token
|
||||
assert!(!row.config.contains("bot:valid123"));
|
||||
assert!(!row.config.contains("token"));
|
||||
|
||||
// Should be valid base64 (encrypted output)
|
||||
assert!(base64_looks_valid(&row.config));
|
||||
|
||||
// Decryption should yield the original config
|
||||
let decrypted = decrypt_string(&row.config, &test_key()).unwrap();
|
||||
let config: PluginConfig = serde_json::from_str(&decrypted).unwrap();
|
||||
assert_eq!(config.credentials.token.as_deref(), Some("bot:valid123"));
|
||||
}
|
||||
|
||||
// ── CS-2: Status response does not leak credentials ───────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn cs2_status_does_not_leak_credentials() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let statuses = mgr.get_plugin_status().await.unwrap();
|
||||
let json = serde_json::to_string(&statuses).unwrap();
|
||||
|
||||
// No sensitive fields should appear
|
||||
assert!(!json.contains("bot:valid123"));
|
||||
assert!(!json.contains("credentials"));
|
||||
assert!(!json.contains("config"));
|
||||
// But the plugin metadata should be there
|
||||
assert!(json.contains("telegram"));
|
||||
assert!(json.contains("Telegram Bot"));
|
||||
}
|
||||
|
||||
// ── WS-2: Plugin status change event broadcast ───────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws2_enable_broadcasts_status_change() {
|
||||
let (mgr, _repo, bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let events = bc.take_events();
|
||||
let status_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.name == "channel.plugin-status-changed")
|
||||
.collect();
|
||||
assert!(!status_events.is_empty());
|
||||
assert_eq!(status_events.last().unwrap().data["plugin_id"], "telegram");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws2_disable_broadcasts_status_change() {
|
||||
let (mgr, _repo, bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
bc.take_events(); // clear enable events
|
||||
|
||||
mgr.disable_plugin("telegram").await.unwrap();
|
||||
|
||||
let events = bc.take_events();
|
||||
let status_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.name == "channel.plugin-status-changed")
|
||||
.collect();
|
||||
assert!(!status_events.is_empty());
|
||||
}
|
||||
|
||||
// ── Restore: enabled plugins start on restore ────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_starts_enabled_plugins() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
// First enable and persist a plugin
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Simulate shutdown
|
||||
mgr.shutdown().await;
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
|
||||
// Restore should bring it back
|
||||
mgr.restore_plugins(&factory).await.unwrap();
|
||||
assert_eq!(mgr.active_plugin_count(), 1);
|
||||
assert!(mgr.is_plugin_running("telegram"));
|
||||
}
|
||||
|
||||
// ── Restore: disabled plugins are skipped ─────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_skips_disabled_plugins() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
mgr.disable_plugin("telegram").await.unwrap();
|
||||
|
||||
mgr.restore_plugins(&factory).await.unwrap();
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// ── Multiple plugins ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_multiple_plugins() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("lark"), &make_lark_config(), &factory).await.unwrap();
|
||||
|
||||
assert_eq!(mgr.active_plugin_count(), 2);
|
||||
assert!(mgr.is_plugin_running("telegram"));
|
||||
assert!(mgr.is_plugin_running("lark"));
|
||||
|
||||
let statuses = mgr.get_plugin_status().await.unwrap();
|
||||
assert_eq!(statuses.len(), 2);
|
||||
}
|
||||
|
||||
// ── Shutdown stops all ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_stops_all() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("lark"), &make_lark_config(), &factory).await.unwrap();
|
||||
|
||||
mgr.shutdown().await;
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// ── Send/Edit message routing ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_routes_to_plugin() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg_id = mgr
|
||||
.send_message("telegram", "chat_1", make_test_outgoing())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg_id, "mock_msg_id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_not_running_fails() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
|
||||
let err = mgr
|
||||
.send_message("telegram", "chat_1", make_test_outgoing())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PluginNotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_message_routes_to_plugin() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_factory();
|
||||
|
||||
mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
mgr.edit_message("telegram", "chat_1", "msg_1", make_test_outgoing())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── Init failure sets error in DB ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_failure_sets_error_in_db() {
|
||||
let (mgr, repo, _bc) = setup().await;
|
||||
let factory = make_failing_factory();
|
||||
|
||||
let err = mgr.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory).await;
|
||||
assert!(err.is_err());
|
||||
|
||||
// Plugin should exist in DB with error status
|
||||
let row = repo.get_plugin("telegram").await.unwrap().unwrap();
|
||||
assert_eq!(row.status.as_deref(), Some("error"));
|
||||
assert_eq!(mgr.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// ── No implementation factory ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_no_implementation_fails() {
|
||||
let (mgr, _repo, _bc) = setup().await;
|
||||
let factory = make_no_impl_factory();
|
||||
|
||||
let err = mgr
|
||||
.enable_plugin(&EnableChannelSpec::legacy("telegram"), &make_telegram_config(), &factory)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::InvalidPluginType(_)));
|
||||
}
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────
|
||||
|
||||
fn base64_looks_valid(s: &str) -> bool {
|
||||
s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=')
|
||||
&& s.len() > 20
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_ai_agent::agent_task::{AgentInstance, IAgentTask};
|
||||
use nomifun_ai_agent::protocol::events::FinishEventData;
|
||||
use nomifun_ai_agent::types::{BuildTaskOptions, SendMessageData};
|
||||
use nomifun_ai_agent::{AgentSendError, AgentStreamEvent, IMockAgent, IWorkerTaskManager};
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::channel_settings::ChannelSettingsService;
|
||||
use nomifun_channel::message_service::ChannelMessageService;
|
||||
use nomifun_channel::types::PluginType;
|
||||
use nomifun_common::{AgentKillReason, AgentType, AppError, ConversationStatus, TimestampMs};
|
||||
use nomifun_conversation::ConversationService;
|
||||
use nomifun_conversation::skill_resolver::{ResolvedAgentSkill, SkillResolver};
|
||||
use nomifun_db::models::AssistantSessionRow;
|
||||
use nomifun_db::{
|
||||
SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteChannelRepository,
|
||||
SqliteClientPreferenceRepository, SqliteConversationRepository, init_database_memory,
|
||||
};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
struct TestBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl TestBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for TestBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopSkillResolver;
|
||||
|
||||
#[async_trait]
|
||||
impl SkillResolver for NoopSkillResolver {
|
||||
async fn auto_inject_names(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn resolve_skills(&self, _names: &[String]) -> Vec<ResolvedAgentSkill> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn link_workspace_skills(
|
||||
&self,
|
||||
_workspace: &std::path::Path,
|
||||
_rel_dirs: &[&str],
|
||||
_skills: &[ResolvedAgentSkill],
|
||||
) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
struct ScriptedAgent {
|
||||
conversation_id: String,
|
||||
event_tx: broadcast::Sender<AgentStreamEvent>,
|
||||
}
|
||||
|
||||
impl ScriptedAgent {
|
||||
fn new(conversation_id: &str) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(16);
|
||||
Self {
|
||||
conversation_id: conversation_id.to_owned(),
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IAgentTask for ScriptedAgent {
|
||||
fn agent_type(&self) -> AgentType {
|
||||
AgentType::Nomi
|
||||
}
|
||||
|
||||
fn conversation_id(&self) -> &str {
|
||||
&self.conversation_id
|
||||
}
|
||||
|
||||
fn workspace(&self) -> &str {
|
||||
"/tmp/nomifun-channel-test"
|
||||
}
|
||||
|
||||
fn status(&self) -> Option<ConversationStatus> {
|
||||
Some(ConversationStatus::Finished)
|
||||
}
|
||||
|
||||
fn last_activity_at(&self) -> TimestampMs {
|
||||
0
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<AgentStreamEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
async fn send_message(&self, _data: SendMessageData) -> Result<(), AgentSendError> {
|
||||
let _ = self.event_tx.send(AgentStreamEvent::Finish(FinishEventData::default()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel(&self) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill(&self, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IMockAgent for ScriptedAgent {}
|
||||
|
||||
struct RecordingTaskManager {
|
||||
agents: Mutex<std::collections::HashMap<String, AgentInstance>>,
|
||||
}
|
||||
|
||||
impl RecordingTaskManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
agents: Mutex::new(std::collections::HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IWorkerTaskManager for RecordingTaskManager {
|
||||
fn get_task(&self, conversation_id: &str) -> Option<AgentInstance> {
|
||||
self.agents.lock().unwrap().get(conversation_id).cloned()
|
||||
}
|
||||
|
||||
async fn get_or_build_task(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
_options: BuildTaskOptions,
|
||||
) -> Result<AgentInstance, AppError> {
|
||||
let mut agents = self.agents.lock().unwrap();
|
||||
if let Some(agent) = agents.get(conversation_id) {
|
||||
return Ok(agent.clone());
|
||||
}
|
||||
|
||||
let agent = AgentInstance::Mock(Arc::new(ScriptedAgent::new(conversation_id)));
|
||||
agents.insert(conversation_id.to_owned(), agent.clone());
|
||||
Ok(agent)
|
||||
}
|
||||
|
||||
fn kill(&self, conversation_id: &str, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
self.agents.lock().unwrap().remove(conversation_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill_and_wait(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
reason: Option<AgentKillReason>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
let _ = self.kill(conversation_id, reason);
|
||||
Box::pin(std::future::ready(()))
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
self.agents.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
fn active_count(&self) -> usize {
|
||||
self.agents.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn collect_idle(&self, _idle_threshold_ms: TimestampMs) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_to_agent_warms_cold_task_before_returning_stream_subscription() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let pool = db.pool().clone();
|
||||
|
||||
let task_manager: Arc<dyn IWorkerTaskManager> = Arc::new(RecordingTaskManager::new());
|
||||
let conversation_svc = Arc::new(ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
Arc::new(TestBroadcaster::new()),
|
||||
Arc::new(NoopSkillResolver),
|
||||
Arc::clone(&task_manager),
|
||||
Arc::new(SqliteConversationRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAgentMetadataRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAcpSessionRepository::new(pool.clone())),
|
||||
));
|
||||
|
||||
let settings = Arc::new(ChannelSettingsService::new(Arc::new(
|
||||
SqliteClientPreferenceRepository::new(pool.clone()),
|
||||
)));
|
||||
let message_svc = ChannelMessageService::new(
|
||||
conversation_svc,
|
||||
Arc::clone(&task_manager),
|
||||
settings,
|
||||
Arc::new(SqliteChannelRepository::new(pool)),
|
||||
"system_default_user".to_owned(),
|
||||
);
|
||||
|
||||
let session = AssistantSessionRow {
|
||||
id: "session-1".to_owned(),
|
||||
user_id: "channel-user-1".to_owned(),
|
||||
agent_type: "nomi".to_owned(),
|
||||
conversation_id: None,
|
||||
workspace: None,
|
||||
chat_id: Some("7088048016".to_owned()),
|
||||
channel_id: None,
|
||||
created_at: 1,
|
||||
last_activity: 1,
|
||||
};
|
||||
|
||||
for platform in [
|
||||
PluginType::Telegram,
|
||||
PluginType::Lark,
|
||||
PluginType::Dingtalk,
|
||||
PluginType::Weixin,
|
||||
] {
|
||||
let result = message_svc.send_to_agent(&session, "hello", platform).await.unwrap();
|
||||
|
||||
assert!(
|
||||
result.stream_rx.is_some(),
|
||||
"channel relay must have an agent stream receiver after cold start for {platform:?}"
|
||||
);
|
||||
assert!(task_manager.get_task(&result.conversation_id).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fix 3/4 support: last_user_text + is_conversation_busy ──────────────
|
||||
|
||||
struct TestStack {
|
||||
conversation_svc: Arc<ConversationService>,
|
||||
message_svc: ChannelMessageService,
|
||||
runtime: Arc<nomifun_conversation::runtime_state::ConversationRuntimeStateService>,
|
||||
channel_repo: Arc<SqliteChannelRepository>,
|
||||
}
|
||||
|
||||
fn build_stack(pool: nomifun_db::SqlitePool) -> TestStack {
|
||||
let task_manager: Arc<dyn IWorkerTaskManager> = Arc::new(RecordingTaskManager::new());
|
||||
let runtime = Arc::new(nomifun_conversation::runtime_state::ConversationRuntimeStateService::default());
|
||||
let conversation_svc = Arc::new(
|
||||
ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
Arc::new(TestBroadcaster::new()),
|
||||
Arc::new(NoopSkillResolver),
|
||||
Arc::clone(&task_manager),
|
||||
Arc::new(SqliteConversationRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAgentMetadataRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAcpSessionRepository::new(pool.clone())),
|
||||
)
|
||||
.with_runtime_state(Arc::clone(&runtime)),
|
||||
);
|
||||
|
||||
let settings = Arc::new(ChannelSettingsService::new(Arc::new(
|
||||
SqliteClientPreferenceRepository::new(pool.clone()),
|
||||
)));
|
||||
let channel_repo = Arc::new(SqliteChannelRepository::new(pool));
|
||||
let message_svc = ChannelMessageService::new(
|
||||
Arc::clone(&conversation_svc),
|
||||
Arc::clone(&task_manager),
|
||||
settings,
|
||||
channel_repo.clone(),
|
||||
"system_default_user".to_owned(),
|
||||
);
|
||||
|
||||
TestStack {
|
||||
conversation_svc,
|
||||
message_svc,
|
||||
runtime,
|
||||
channel_repo,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_session(conversation_id: Option<i64>) -> AssistantSessionRow {
|
||||
AssistantSessionRow {
|
||||
id: "session-1".to_owned(),
|
||||
user_id: "channel-user-1".to_owned(),
|
||||
agent_type: "nomi".to_owned(),
|
||||
conversation_id,
|
||||
workspace: None,
|
||||
chat_id: Some("7088048016".to_owned()),
|
||||
channel_id: None,
|
||||
created_at: 1,
|
||||
last_activity: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for the background turn spawned by `send_message` to release its
|
||||
/// runtime claim so the next send doesn't hit the turn-conflict guard.
|
||||
async fn wait_until_idle(svc: &Arc<ConversationService>, conversation_id: &str) {
|
||||
use nomifun_api_types::ConversationRuntimeStateKind;
|
||||
for _ in 0..500 {
|
||||
let summary = svc.runtime_summary_for(conversation_id).await;
|
||||
if summary.state == ConversationRuntimeStateKind::Idle {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("conversation {conversation_id} never became idle");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn last_user_text_returns_latest_user_prompt() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
|
||||
// First prompt creates the conversation; second one is the newest.
|
||||
let session = make_session(None);
|
||||
let first = stack
|
||||
.message_svc
|
||||
.send_to_agent(&session, "first prompt", PluginType::Telegram)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until_idle(&stack.conversation_svc, &first.conversation_id).await;
|
||||
|
||||
// SendResult.conversation_id is a String (Option A); the session FK is i64.
|
||||
let bound_session = make_session(Some(first.conversation_id.parse::<i64>().unwrap()));
|
||||
stack
|
||||
.message_svc
|
||||
.send_to_agent(&bound_session, "second prompt", PluginType::Telegram)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until_idle(&stack.conversation_svc, &first.conversation_id).await;
|
||||
|
||||
let text = stack.message_svc.last_user_text(&first.conversation_id).await.unwrap();
|
||||
assert_eq!(text.as_deref(), Some("second prompt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn last_user_text_none_for_unknown_conversation() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
|
||||
// Unknown conversation maps to a lookup error, not a silent None.
|
||||
let result = stack.message_svc.last_user_text("missing-conv").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_conversation_busy_reflects_turn_claim() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
|
||||
let session = make_session(None);
|
||||
let sent = stack
|
||||
.message_svc
|
||||
.send_to_agent(&session, "hello", PluginType::Telegram)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_until_idle(&stack.conversation_svc, &sent.conversation_id).await;
|
||||
|
||||
assert!(!stack.message_svc.is_conversation_busy(&sent.conversation_id).await);
|
||||
|
||||
// Claiming the turn is exactly what send_message does while a prompt is
|
||||
// in flight → the channel guard must report busy.
|
||||
let _claim = stack.runtime.try_claim_turn(&sent.conversation_id).unwrap();
|
||||
assert!(stack.message_svc.is_conversation_busy(&sent.conversation_id).await);
|
||||
|
||||
drop(_claim);
|
||||
assert!(!stack.message_svc.is_conversation_busy(&sent.conversation_id).await);
|
||||
}
|
||||
|
||||
// ── Channel companion binding resolution + single-session routing ──────────────
|
||||
|
||||
/// Profile stub: maps each companion id to a pre-seeded single-session
|
||||
/// conversation id (what `CompanionManager.create` would return in production),
|
||||
/// records every `ensure_companion_session` call, and uses `companion_y` as the
|
||||
/// legacy/default per-platform fallback. An empty `sessions` map models a
|
||||
/// companion with no chat model configured (ensure returns `None`).
|
||||
struct StubProfile {
|
||||
sessions: std::collections::HashMap<String, i64>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl StubProfile {
|
||||
fn new(sessions: std::collections::HashMap<String, i64>) -> Self {
|
||||
Self {
|
||||
sessions,
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl nomifun_channel::message_service::MasterAgentProfile for StubProfile {
|
||||
async fn companion_model(&self, _companion_id: &str) -> Option<nomifun_common::ProviderWithModel> {
|
||||
None
|
||||
}
|
||||
async fn master_companion_id(&self, _platform: &str) -> Option<String> {
|
||||
Some("companion_y".to_owned())
|
||||
}
|
||||
async fn companion_exists(&self, _companion_id: &str) -> bool {
|
||||
true
|
||||
}
|
||||
async fn ensure_companion_session(&self, companion_id: &str) -> Option<i64> {
|
||||
self.calls.lock().unwrap().push(companion_id.to_owned());
|
||||
self.sessions.get(companion_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed a companion's single-session conversation (the row `CompanionManager`
|
||||
/// would own), returning its i64 id.
|
||||
async fn seed_companion_session(svc: &Arc<ConversationService>, companion_id: &str) -> i64 {
|
||||
let req = nomifun_api_types::CreateConversationRequest {
|
||||
r#type: AgentType::Nomi,
|
||||
name: Some(format!("和 {companion_id} 聊天")),
|
||||
model: Some(nomifun_common::ProviderWithModel {
|
||||
provider_id: "p".to_owned(),
|
||||
model: "m".to_owned(),
|
||||
use_model: Some("m".to_owned()),
|
||||
}),
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
extra: serde_json::json!({ "companionSession": true, "companionId": companion_id }),
|
||||
};
|
||||
svc.create("system_default_user", req).await.unwrap().id
|
||||
}
|
||||
|
||||
async fn bind_channel_to_companion(repo: &Arc<SqliteChannelRepository>, channel_id: &str, companion_id: &str) {
|
||||
use nomifun_db::IChannelRepository;
|
||||
let now = nomifun_common::now_ms();
|
||||
repo.upsert_plugin(&nomifun_db::models::ChannelPluginRow {
|
||||
id: channel_id.to_owned(),
|
||||
r#type: "telegram".to_owned(),
|
||||
name: "Telegram Bot".to_owned(),
|
||||
enabled: true,
|
||||
config: "enc".to_owned(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: Some(companion_id.to_owned()),
|
||||
bot_key: Some("42".to_owned()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The channel row's own companion binding wins over the profile fallback, and
|
||||
/// either way the turn is routed INTO that companion's single session (not a
|
||||
/// freshly-minted channel-master conversation).
|
||||
#[tokio::test]
|
||||
async fn channel_companion_turn_routes_into_companion_single_session() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
|
||||
let conv_x = seed_companion_session(&stack.conversation_svc, "companion_x").await;
|
||||
let conv_y = seed_companion_session(&stack.conversation_svc, "companion_y").await;
|
||||
let sessions = std::collections::HashMap::from([
|
||||
("companion_x".to_owned(), conv_x),
|
||||
("companion_y".to_owned(), conv_y),
|
||||
]);
|
||||
let message_svc = stack.message_svc.with_master_profile(Arc::new(StubProfile::new(sessions)));
|
||||
|
||||
bind_channel_to_companion(&stack.channel_repo, "achn_test", "companion_x").await;
|
||||
|
||||
// Bound channel → channel companion (companion_x) wins; the turn runs on
|
||||
// companion_x's single session conversation, NOT a new channel conversation.
|
||||
let mut bound = make_session(None);
|
||||
bound.channel_id = Some("achn_test".to_owned());
|
||||
let sent = message_svc.send_to_agent(&bound, "hi", PluginType::Telegram).await.unwrap();
|
||||
assert_eq!(sent.conversation_id, conv_x.to_string());
|
||||
wait_until_idle(&stack.conversation_svc, &sent.conversation_id).await;
|
||||
|
||||
// No channel binding → profile fallback companion (companion_y) → its session.
|
||||
let mut unbound = make_session(None);
|
||||
unbound.id = "session-2".to_owned();
|
||||
unbound.chat_id = Some("other-chat".to_owned());
|
||||
let sent = message_svc.send_to_agent(&unbound, "hi", PluginType::Telegram).await.unwrap();
|
||||
assert_eq!(sent.conversation_id, conv_y.to_string());
|
||||
}
|
||||
|
||||
/// Two different IM chats bound to the SAME companion both land in that
|
||||
/// companion's ONE session — the unification guarantee. No standalone
|
||||
/// channel-master conversation is created for either.
|
||||
#[tokio::test]
|
||||
async fn companion_im_turns_share_one_session() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
|
||||
let conv_x = seed_companion_session(&stack.conversation_svc, "companion_x").await;
|
||||
let sessions = std::collections::HashMap::from([("companion_x".to_owned(), conv_x)]);
|
||||
let message_svc = stack.message_svc.with_master_profile(Arc::new(StubProfile::new(sessions)));
|
||||
bind_channel_to_companion(&stack.channel_repo, "achn_test", "companion_x").await;
|
||||
|
||||
let mut chat_a = make_session(None);
|
||||
chat_a.channel_id = Some("achn_test".to_owned());
|
||||
chat_a.chat_id = Some("chat-A".to_owned());
|
||||
let a = message_svc.send_to_agent(&chat_a, "hi from A", PluginType::Telegram).await.unwrap();
|
||||
wait_until_idle(&stack.conversation_svc, &a.conversation_id).await;
|
||||
|
||||
let mut chat_b = make_session(None);
|
||||
chat_b.id = "session-b".to_owned();
|
||||
chat_b.channel_id = Some("achn_test".to_owned());
|
||||
chat_b.chat_id = Some("chat-B".to_owned());
|
||||
let b = message_svc.send_to_agent(&chat_b, "hi from B", PluginType::Telegram).await.unwrap();
|
||||
|
||||
assert_eq!(a.conversation_id, conv_x.to_string());
|
||||
assert_eq!(b.conversation_id, conv_x.to_string(), "both IM chats must share the companion's single session");
|
||||
}
|
||||
|
||||
/// A companion with no chat model (ensure returns None) refuses the turn with a
|
||||
/// distinct error instead of silently minting a leaking standalone conversation.
|
||||
#[tokio::test]
|
||||
async fn companion_without_model_refuses_turn() {
|
||||
use nomifun_channel::error::ChannelError;
|
||||
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let stack = build_stack(db.pool().clone());
|
||||
// Empty sessions map → ensure_companion_session returns None for every companion.
|
||||
let message_svc = stack
|
||||
.message_svc
|
||||
.with_master_profile(Arc::new(StubProfile::new(std::collections::HashMap::new())));
|
||||
bind_channel_to_companion(&stack.channel_repo, "achn_test", "companion_x").await;
|
||||
|
||||
let mut bound = make_session(None);
|
||||
bound.channel_id = Some("achn_test".to_owned());
|
||||
let err = message_svc
|
||||
.send_to_agent(&bound, "hi", PluginType::Telegram)
|
||||
.await
|
||||
.expect_err("a model-less companion must refuse the turn");
|
||||
assert!(matches!(err, ChannelError::CompanionNotReady(_)));
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_ai_agent::agent_task::{AgentInstance, IAgentTask};
|
||||
use nomifun_ai_agent::protocol::events::FinishEventData;
|
||||
use nomifun_ai_agent::types::{BuildTaskOptions, SendMessageData};
|
||||
use nomifun_ai_agent::{AgentSendError, AgentStreamEvent, IMockAgent, IWorkerTaskManager};
|
||||
use nomifun_api_types::{ConversationRuntimeStateKind, ListMessagesQuery, WebSocketMessage};
|
||||
use nomifun_channel::action::{ActionExecutor, MessageResult};
|
||||
use nomifun_channel::channel_settings::ChannelSettingsService;
|
||||
use nomifun_channel::message_service::ChannelMessageService;
|
||||
use nomifun_channel::orchestrator::ChannelOrchestrator;
|
||||
use nomifun_channel::pairing::PairingService;
|
||||
use nomifun_channel::session::SessionManager;
|
||||
use nomifun_channel::stream_relay::{ChannelSender, MessageRecorder};
|
||||
use nomifun_channel::types::{
|
||||
ActionCategory, ActionContext, ChannelIncoming, MessageContentType, PluginType, UnifiedAction,
|
||||
UnifiedIncomingMessage, UnifiedMessageContent, UnifiedOutgoingMessage, UnifiedUser,
|
||||
};
|
||||
use nomifun_common::{
|
||||
AgentKillReason, AgentType, AppError, ConversationStatus, MessagePosition, TimestampMs, now_ms,
|
||||
};
|
||||
use nomifun_conversation::ConversationService;
|
||||
use nomifun_conversation::runtime_state::ConversationRuntimeStateService;
|
||||
use nomifun_conversation::skill_resolver::{ResolvedAgentSkill, SkillResolver};
|
||||
use nomifun_db::models::{AssistantUserRow, ChannelPluginRow};
|
||||
use nomifun_db::{
|
||||
IChannelRepository, SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteChannelRepository,
|
||||
SqliteClientPreferenceRepository, SqliteConversationRepository,
|
||||
};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
/// The channel row id every test message arrives through.
|
||||
const TEST_CHANNEL: &str = "tg-1";
|
||||
|
||||
/// Stamps a platform message with the test channel id, the way the
|
||||
/// manager's per-instance forwarder does in production.
|
||||
fn incoming(message: UnifiedIncomingMessage) -> ChannelIncoming {
|
||||
ChannelIncoming {
|
||||
channel_id: TEST_CHANNEL.into(),
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_text_message(user_id: &str, chat_id: &str, text: &str) -> UnifiedIncomingMessage {
|
||||
UnifiedIncomingMessage {
|
||||
id: "msg-1".into(),
|
||||
platform: PluginType::Telegram,
|
||||
chat_id: chat_id.into(),
|
||||
user: UnifiedUser {
|
||||
id: user_id.into(),
|
||||
username: None,
|
||||
display_name: "Test".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Text,
|
||||
text: text.into(),
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: 0,
|
||||
reply_to_message_id: None,
|
||||
action: None,
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_chat_action_message(user_id: &str, chat_id: &str, action_name: &str) -> UnifiedIncomingMessage {
|
||||
UnifiedIncomingMessage {
|
||||
id: "msg-action".into(),
|
||||
platform: PluginType::Telegram,
|
||||
chat_id: chat_id.into(),
|
||||
user: UnifiedUser {
|
||||
id: user_id.into(),
|
||||
username: None,
|
||||
display_name: "Test".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Action,
|
||||
text: String::new(),
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: 0,
|
||||
reply_to_message_id: None,
|
||||
action: Some(UnifiedAction {
|
||||
action: action_name.into(),
|
||||
category: ActionCategory::Chat,
|
||||
params: None,
|
||||
context: ActionContext {
|
||||
platform: PluginType::Telegram,
|
||||
user_id: user_id.into(),
|
||||
chat_id: chat_id.into(),
|
||||
message_id: None,
|
||||
session_id: None,
|
||||
},
|
||||
}),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unauthorized user should receive a pairing code response.
|
||||
#[tokio::test]
|
||||
async fn unauthorized_user_gets_pairing_response() {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let pool = db.pool().clone();
|
||||
let repo: Arc<dyn nomifun_db::IChannelRepository> =
|
||||
Arc::new(nomifun_db::SqliteChannelRepository::new(pool.clone()));
|
||||
let bus = Arc::new(nomifun_realtime::BroadcastEventBus::new(64));
|
||||
|
||||
let pref_repo: Arc<dyn nomifun_db::IClientPreferenceRepository> =
|
||||
Arc::new(nomifun_db::SqliteClientPreferenceRepository::new(pool));
|
||||
let settings = Arc::new(ChannelSettingsService::new(pref_repo));
|
||||
|
||||
let pairing = Arc::new(PairingService::new(repo.clone(), bus));
|
||||
let session_mgr = Arc::new(SessionManager::new(repo.clone()));
|
||||
let executor = Arc::new(ActionExecutor::new(pairing, Arc::clone(&session_mgr), settings, "acp"));
|
||||
|
||||
// The pairing code created for the unauthorized user carries an FK
|
||||
// channel_id → assistant_plugins(id), so the bot channel must exist first.
|
||||
repo.upsert_plugin(&ChannelPluginRow {
|
||||
id: TEST_CHANNEL.into(),
|
||||
r#type: "telegram".into(),
|
||||
name: "Test Bot".into(),
|
||||
enabled: true,
|
||||
config: "{}".into(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg = make_text_message("unknown_user", "chat_1", "hello");
|
||||
let result = executor.handle_incoming_message(&msg, TEST_CHANNEL).await.unwrap();
|
||||
|
||||
match result {
|
||||
MessageResult::Action(response) => {
|
||||
let text = response.text.unwrap();
|
||||
assert!(text.len() > 5, "expected pairing response, got: {text}");
|
||||
}
|
||||
other => panic!("expected Action, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// Full-pipeline tests: busy guard, chat.continue, chat.regenerate
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
struct TestBroadcaster;
|
||||
|
||||
impl EventBroadcaster for TestBroadcaster {
|
||||
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
|
||||
}
|
||||
|
||||
struct NoopSkillResolver;
|
||||
|
||||
#[async_trait]
|
||||
impl SkillResolver for NoopSkillResolver {
|
||||
async fn auto_inject_names(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn resolve_skills(&self, _names: &[String]) -> Vec<ResolvedAgentSkill> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn link_workspace_skills(
|
||||
&self,
|
||||
_workspace: &std::path::Path,
|
||||
_rel_dirs: &[&str],
|
||||
_skills: &[ResolvedAgentSkill],
|
||||
) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
struct ScriptedAgent {
|
||||
conversation_id: String,
|
||||
event_tx: broadcast::Sender<AgentStreamEvent>,
|
||||
}
|
||||
|
||||
impl ScriptedAgent {
|
||||
fn new(conversation_id: &str) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(16);
|
||||
Self {
|
||||
conversation_id: conversation_id.to_owned(),
|
||||
event_tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IAgentTask for ScriptedAgent {
|
||||
fn agent_type(&self) -> AgentType {
|
||||
AgentType::Nomi
|
||||
}
|
||||
|
||||
fn conversation_id(&self) -> &str {
|
||||
&self.conversation_id
|
||||
}
|
||||
|
||||
fn workspace(&self) -> &str {
|
||||
"/tmp/nomifun-channel-test"
|
||||
}
|
||||
|
||||
fn status(&self) -> Option<ConversationStatus> {
|
||||
Some(ConversationStatus::Finished)
|
||||
}
|
||||
|
||||
fn last_activity_at(&self) -> TimestampMs {
|
||||
0
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<AgentStreamEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
async fn send_message(&self, _data: SendMessageData) -> Result<(), AgentSendError> {
|
||||
let _ = self.event_tx.send(AgentStreamEvent::Finish(FinishEventData::default()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel(&self) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill(&self, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IMockAgent for ScriptedAgent {}
|
||||
|
||||
struct RecordingTaskManager {
|
||||
agents: Mutex<std::collections::HashMap<String, AgentInstance>>,
|
||||
}
|
||||
|
||||
impl RecordingTaskManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
agents: Mutex::new(std::collections::HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IWorkerTaskManager for RecordingTaskManager {
|
||||
fn get_task(&self, conversation_id: &str) -> Option<AgentInstance> {
|
||||
self.agents.lock().unwrap().get(conversation_id).cloned()
|
||||
}
|
||||
|
||||
async fn get_or_build_task(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
_options: BuildTaskOptions,
|
||||
) -> Result<AgentInstance, AppError> {
|
||||
let mut agents = self.agents.lock().unwrap();
|
||||
if let Some(agent) = agents.get(conversation_id) {
|
||||
return Ok(agent.clone());
|
||||
}
|
||||
|
||||
let agent = AgentInstance::Mock(Arc::new(ScriptedAgent::new(conversation_id)));
|
||||
agents.insert(conversation_id.to_owned(), agent.clone());
|
||||
Ok(agent)
|
||||
}
|
||||
|
||||
fn kill(&self, conversation_id: &str, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
self.agents.lock().unwrap().remove(conversation_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill_and_wait(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
reason: Option<AgentKillReason>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
let _ = self.kill(conversation_id, reason);
|
||||
Box::pin(std::future::ready(()))
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
self.agents.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
fn active_count(&self) -> usize {
|
||||
self.agents.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn collect_idle(&self, _idle_threshold_ms: TimestampMs) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to drive the orchestrator end-to-end with an in-memory
|
||||
/// DB, a scripted agent, and a recording channel sender.
|
||||
struct Harness {
|
||||
message_tx: mpsc::Sender<ChannelIncoming>,
|
||||
/// Held so the orchestrator's confirm branch stays open.
|
||||
_confirm_tx: mpsc::Sender<(String, String)>,
|
||||
recorder: Arc<MessageRecorder>,
|
||||
channel_repo: Arc<dyn IChannelRepository>,
|
||||
conversation_svc: Arc<ConversationService>,
|
||||
runtime: Arc<ConversationRuntimeStateService>,
|
||||
/// The shared pending-decision store the orchestrator's relay/interception
|
||||
/// uses, so tests can seed and inspect pending decisions.
|
||||
pending_decisions: Arc<nomifun_channel::pending_decision::PendingDecisionStore>,
|
||||
}
|
||||
|
||||
async fn build_harness() -> Harness {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let pool = db.pool().clone();
|
||||
|
||||
let channel_repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(pool.clone()));
|
||||
let bus = Arc::new(nomifun_realtime::BroadcastEventBus::new(64));
|
||||
let settings = Arc::new(ChannelSettingsService::new(Arc::new(SqliteClientPreferenceRepository::new(
|
||||
pool.clone(),
|
||||
))));
|
||||
let pairing = Arc::new(PairingService::new(channel_repo.clone(), bus));
|
||||
let session_mgr = Arc::new(SessionManager::new(channel_repo.clone()));
|
||||
let executor = Arc::new(ActionExecutor::new(
|
||||
pairing,
|
||||
Arc::clone(&session_mgr),
|
||||
Arc::clone(&settings),
|
||||
"nomi",
|
||||
));
|
||||
|
||||
// Every test message arrives through TEST_CHANNEL ("tg-1"). assistant_sessions
|
||||
// now has an FK channel_id → assistant_plugins(id), so the plugin row must
|
||||
// exist before any session is created. bot_key=None avoids the
|
||||
// UNIQUE(type, bot_key) index.
|
||||
channel_repo
|
||||
.upsert_plugin(&ChannelPluginRow {
|
||||
id: TEST_CHANNEL.into(),
|
||||
r#type: "telegram".into(),
|
||||
name: "Test Bot".into(),
|
||||
enabled: true,
|
||||
config: "{}".into(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Authorize the test user so messages reach the dispatch path.
|
||||
channel_repo
|
||||
.create_user(&AssistantUserRow {
|
||||
id: "user_tg_42".into(),
|
||||
platform_user_id: "tg_42".into(),
|
||||
platform_type: "telegram".into(),
|
||||
channel_id: Some(TEST_CHANNEL.into()),
|
||||
display_name: Some("Test".into()),
|
||||
authorized_at: now_ms(),
|
||||
last_active: None,
|
||||
session_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let task_manager: Arc<dyn IWorkerTaskManager> = Arc::new(RecordingTaskManager::new());
|
||||
let runtime = Arc::new(ConversationRuntimeStateService::default());
|
||||
let conversation_svc = Arc::new(
|
||||
ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
Arc::new(TestBroadcaster),
|
||||
Arc::new(NoopSkillResolver),
|
||||
Arc::clone(&task_manager),
|
||||
Arc::new(SqliteConversationRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAgentMetadataRepository::new(pool.clone())),
|
||||
Arc::new(SqliteAcpSessionRepository::new(pool.clone())),
|
||||
)
|
||||
.with_runtime_state(Arc::clone(&runtime)),
|
||||
);
|
||||
let message_svc = Arc::new(ChannelMessageService::new(
|
||||
Arc::clone(&conversation_svc),
|
||||
Arc::clone(&task_manager),
|
||||
settings,
|
||||
channel_repo.clone(),
|
||||
"system_default_user".to_owned(),
|
||||
));
|
||||
let pending_decisions = message_svc.pending_decisions();
|
||||
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
let orchestrator = ChannelOrchestrator::new(
|
||||
executor,
|
||||
message_svc,
|
||||
session_mgr,
|
||||
Arc::clone(&recorder) as Arc<dyn ChannelSender>,
|
||||
);
|
||||
|
||||
let (message_tx, message_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, confirm_rx) = mpsc::channel(16);
|
||||
tokio::spawn(orchestrator.run(message_rx, confirm_rx));
|
||||
|
||||
Harness {
|
||||
message_tx,
|
||||
_confirm_tx: confirm_tx,
|
||||
recorder,
|
||||
channel_repo,
|
||||
conversation_svc,
|
||||
runtime,
|
||||
pending_decisions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the channel sessions until one has a bound conversation.
|
||||
async fn wait_for_bound_conversation(repo: &Arc<dyn IChannelRepository>) -> String {
|
||||
for _ in 0..500 {
|
||||
let sessions = repo.get_all_sessions().await.unwrap();
|
||||
// Session FK is now i64; this helper returns a String for the
|
||||
// string-keyed downstream calls (Option A).
|
||||
if let Some(cid) = sessions.iter().find_map(|s| s.conversation_id.map(|id| id.to_string())) {
|
||||
return cid;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("no session was bound to a conversation");
|
||||
}
|
||||
|
||||
/// Waits for the runtime turn claim of `conversation_id` to be released.
|
||||
async fn wait_until_idle(svc: &Arc<ConversationService>, conversation_id: &str) {
|
||||
for _ in 0..500 {
|
||||
let summary = svc.runtime_summary_for(conversation_id).await;
|
||||
if summary.state == ConversationRuntimeStateKind::Idle {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("conversation {conversation_id} never became idle");
|
||||
}
|
||||
|
||||
/// Drains the recorder until a send containing `needle` shows up.
|
||||
async fn wait_for_send_containing(recorder: &Arc<MessageRecorder>, needle: &str) -> UnifiedOutgoingMessage {
|
||||
let mut seen: Vec<UnifiedOutgoingMessage> = Vec::new();
|
||||
for _ in 0..500 {
|
||||
seen.extend(recorder.take_sends());
|
||||
if let Some(found) = seen
|
||||
.iter()
|
||||
.find(|m| m.text.as_deref().is_some_and(|t| t.contains(needle)))
|
||||
{
|
||||
return found.clone();
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("no send containing {needle:?}; saw: {seen:?}");
|
||||
}
|
||||
|
||||
/// Returns the visible user (`right`) message texts of a conversation.
|
||||
async fn user_messages(svc: &Arc<ConversationService>, conversation_id: &str) -> Vec<String> {
|
||||
let query = ListMessagesQuery {
|
||||
page: Some(1),
|
||||
page_size: Some(50),
|
||||
order: Some("ASC".into()),
|
||||
content_mode: None,
|
||||
cursor: None,
|
||||
};
|
||||
let result = svc
|
||||
.list_messages("system_default_user", conversation_id, query)
|
||||
.await
|
||||
.unwrap();
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.filter(|m| m.position == Some(MessagePosition::Right))
|
||||
.filter_map(|m| m.content.get("content").and_then(|v| v.as_str()).map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Polls until the conversation has `expected` visible user messages.
|
||||
async fn wait_for_user_message_count(
|
||||
svc: &Arc<ConversationService>,
|
||||
conversation_id: &str,
|
||||
expected: usize,
|
||||
) -> Vec<String> {
|
||||
let mut last = Vec::new();
|
||||
for _ in 0..500 {
|
||||
last = user_messages(svc, conversation_id).await;
|
||||
if last.len() >= expected {
|
||||
return last;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("conversation never reached {expected} user messages; got {last:?}");
|
||||
}
|
||||
|
||||
/// Fix 4: a second message for a busy conversation must be answered with the
|
||||
/// "still processing" notice instead of racing a second prompt.
|
||||
#[tokio::test]
|
||||
async fn busy_conversation_replies_with_processing_notice() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "hello world")))
|
||||
.await
|
||||
.unwrap();
|
||||
let cid = wait_for_bound_conversation(&harness.channel_repo).await;
|
||||
wait_until_idle(&harness.conversation_svc, &cid).await;
|
||||
|
||||
// Simulate an in-flight turn exactly the way send_message does.
|
||||
let _claim = harness.runtime.try_claim_turn(&cid).unwrap();
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "second message")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_send_containing(&harness.recorder, "still being processed").await;
|
||||
|
||||
// The guard fired before send_to_agent: no second user message was
|
||||
// persisted into the conversation.
|
||||
let messages = user_messages(&harness.conversation_svc, &cid).await;
|
||||
assert_eq!(messages, vec!["hello world".to_string()]);
|
||||
}
|
||||
|
||||
/// Fix 3: chat.continue dispatches the fixed continue prompt as a user turn
|
||||
/// through the regular streaming pipeline.
|
||||
#[tokio::test]
|
||||
async fn chat_continue_sends_continue_prompt_to_agent() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "hello world")))
|
||||
.await
|
||||
.unwrap();
|
||||
let cid = wait_for_bound_conversation(&harness.channel_repo).await;
|
||||
wait_until_idle(&harness.conversation_svc, &cid).await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_chat_action_message("tg_42", "chat_1", "chat.continue")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let messages = wait_for_user_message_count(&harness.conversation_svc, &cid, 2).await;
|
||||
assert_eq!(messages, vec![
|
||||
"hello world".to_string(),
|
||||
nomifun_channel::action::CONTINUE_PROMPT.to_string()
|
||||
]);
|
||||
}
|
||||
|
||||
/// Fix 3: chat.regenerate resends the conversation's last user message.
|
||||
#[tokio::test]
|
||||
async fn chat_regenerate_resends_last_user_message() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "hello world")))
|
||||
.await
|
||||
.unwrap();
|
||||
let cid = wait_for_bound_conversation(&harness.channel_repo).await;
|
||||
wait_until_idle(&harness.conversation_svc, &cid).await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_chat_action_message("tg_42", "chat_1", "chat.regenerate")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let messages = wait_for_user_message_count(&harness.conversation_svc, &cid, 2).await;
|
||||
assert_eq!(messages, vec!["hello world".to_string(), "hello world".to_string()]);
|
||||
}
|
||||
|
||||
/// Fix 3: chat.regenerate before any message exists must reply with a
|
||||
/// helpful notice instead of silently doing nothing.
|
||||
#[tokio::test]
|
||||
async fn chat_regenerate_without_history_replies_with_notice() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_chat_action_message("tg_42", "chat_1", "chat.regenerate")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_send_containing(&harness.recorder, "no previous message to regenerate").await;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// Bug 1, Case A: relayed decision → numbered reply interception
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
use nomifun_channel::pending_decision::PendingDecision;
|
||||
use nomifun_channel::types::DecisionOption;
|
||||
|
||||
/// Seeds a two-option pending decision for `conversation_id`.
|
||||
fn seed_decision(harness: &Harness, conversation_id: &str) {
|
||||
harness.pending_decisions.put(PendingDecision {
|
||||
conversation_id: conversation_id.to_owned(),
|
||||
call_id: "call-dec".into(),
|
||||
prompt: "Proceed?".into(),
|
||||
options: vec![
|
||||
DecisionOption {
|
||||
option_id: "allow".into(),
|
||||
label: "Allow".into(),
|
||||
},
|
||||
DecisionOption {
|
||||
option_id: "reject".into(),
|
||||
label: "Reject".into(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/// A numeric reply to a pending decision resolves it (ack + cleared store)
|
||||
/// and is NOT dispatched as a new user prompt.
|
||||
#[tokio::test]
|
||||
async fn decision_numeric_reply_resolves_and_does_not_dispatch() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
// Establish a bound conversation with exactly one user message.
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "hello world")))
|
||||
.await
|
||||
.unwrap();
|
||||
let cid = wait_for_bound_conversation(&harness.channel_repo).await;
|
||||
wait_until_idle(&harness.conversation_svc, &cid).await;
|
||||
|
||||
// The conversation is now blocked on a decision.
|
||||
seed_decision(&harness, &cid);
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "2")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Ack confirms the chosen label.
|
||||
wait_for_send_containing(&harness.recorder, "已选择:Reject").await;
|
||||
|
||||
// Pending entry cleared.
|
||||
for _ in 0..500 {
|
||||
if harness.pending_decisions.peek(&cid).is_none() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(harness.pending_decisions.peek(&cid).is_none(), "pending decision must be cleared");
|
||||
|
||||
// No second user message was dispatched — the reply was consumed.
|
||||
let messages = user_messages(&harness.conversation_svc, &cid).await;
|
||||
assert_eq!(messages, vec!["hello world".to_string()]);
|
||||
}
|
||||
|
||||
/// A non-numeric reply while a decision is pending re-shows the numbered list
|
||||
/// and is NOT dispatched.
|
||||
#[tokio::test]
|
||||
async fn decision_non_numeric_reply_reshows_list_and_does_not_dispatch() {
|
||||
let harness = build_harness().await;
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "hello world")))
|
||||
.await
|
||||
.unwrap();
|
||||
let cid = wait_for_bound_conversation(&harness.channel_repo).await;
|
||||
wait_until_idle(&harness.conversation_svc, &cid).await;
|
||||
|
||||
seed_decision(&harness, &cid);
|
||||
|
||||
harness
|
||||
.message_tx
|
||||
.send(incoming(make_text_message("tg_42", "chat_1", "what?")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The numbered list is re-shown.
|
||||
let reshow = wait_for_send_containing(&harness.recorder, "需要你的决策").await;
|
||||
let text = reshow.text.unwrap();
|
||||
assert!(text.contains("1. Allow"), "re-shown list numbered: {text}");
|
||||
assert!(text.contains("2. Reject"), "re-shown list numbered: {text}");
|
||||
|
||||
// Pending entry survives (the user still has to answer).
|
||||
assert!(harness.pending_decisions.peek(&cid).is_some(), "pending decision must survive a bad reply");
|
||||
|
||||
// No new user message dispatched.
|
||||
let messages = user_messages(&harness.conversation_svc, &cid).await;
|
||||
assert_eq!(messages, vec!["hello world".to_string()]);
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
//! Black-box integration tests for `PairingService`.
|
||||
//!
|
||||
//! Uses real SQLite (in-memory) and mock EventBroadcaster.
|
||||
//! Covers test-plan items: PG-1..PG-3, AP-1..AP-6, RP-1..RP-4,
|
||||
//! PP-1..PP-3, EC-1..EC-2, DC-2..DC-3, WS-1, WS-3.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_common::{TimestampMs, now_ms};
|
||||
use nomifun_db::models::{ChannelPluginRow, PairingCodeRow};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
use nomifun_channel::constants::{PAIRING_CODE_LENGTH, PAIRING_CODE_TTL};
|
||||
use nomifun_channel::error::ChannelError;
|
||||
use nomifun_channel::pairing::PairingService;
|
||||
|
||||
/// Telegram bot channel id used by the integration tests. `assistant_pairing_codes`
|
||||
/// and `assistant_users` carry an FK channel_id → assistant_plugins(id), so the
|
||||
/// plugin rows must exist before any pairing is created.
|
||||
const CH_TG: &str = "tg-1";
|
||||
/// Lark bot channel id (second platform exercised by these tests).
|
||||
const CH_LARK: &str = "lark-1";
|
||||
/// A second lark bot channel id. Same platform as `CH_LARK`, different bot —
|
||||
/// used to prove pairing/auth are scoped per bot (channel), not per platform.
|
||||
/// `setup()` does not seed this; the multi-bot test seeds it itself so the
|
||||
/// channel_id FK is satisfied.
|
||||
const CH_LARK2: &str = "lark-2";
|
||||
|
||||
// ── Test infrastructure ─────────────────────────────────────────────
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
|
||||
let mut guard = self.events.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> (PairingService, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let bc = Arc::new(MockBroadcaster::new());
|
||||
let svc = PairingService::new(repo.clone(), bc.clone());
|
||||
|
||||
// Seed the bot channels the tests pair against. assistant_pairing_codes /
|
||||
// assistant_users have an FK channel_id → assistant_plugins(id), so these
|
||||
// rows must exist before request_pairing inserts a code.
|
||||
for (id, ty, name) in [(CH_TG, "telegram", "Telegram Bot"), (CH_LARK, "lark", "Lark Bot")] {
|
||||
repo.upsert_plugin(&ChannelPluginRow {
|
||||
id: id.into(),
|
||||
r#type: ty.into(),
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
config: "{}".into(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Keep db alive by leaking — test process exits anyway
|
||||
std::mem::forget(db);
|
||||
(svc, repo, bc)
|
||||
}
|
||||
|
||||
// ── PG-1: Generated code is 6 digits ───────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pg1_code_is_six_digits() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
assert_eq!(code.len(), PAIRING_CODE_LENGTH);
|
||||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
||||
}
|
||||
|
||||
// ── PG-2: Code expires after 10 minutes ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pg2_code_expires_after_ten_minutes() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let before = now_ms();
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
let after = now_ms();
|
||||
|
||||
let row = repo.get_pairing_by_code(&code).await.unwrap().unwrap();
|
||||
let ttl = PAIRING_CODE_TTL.as_millis() as TimestampMs;
|
||||
assert!(row.expires_at >= before + ttl);
|
||||
assert!(row.expires_at <= after + ttl);
|
||||
}
|
||||
|
||||
// ── PG-3: Same user re-request expires old code ────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pg3_same_user_re_request_expires_old_code() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let code1 = svc.request_pairing("u1", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
let code2 = svc.request_pairing("u1", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
|
||||
assert_ne!(code1, code2);
|
||||
|
||||
let old = repo.get_pairing_by_code(&code1).await.unwrap().unwrap();
|
||||
let new = repo.get_pairing_by_code(&code2).await.unwrap().unwrap();
|
||||
assert_eq!(old.status, "expired");
|
||||
assert_eq!(new.status, "pending");
|
||||
}
|
||||
|
||||
// ── PP-1: No pending pairings returns empty ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pp1_no_pending_returns_empty() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
// ── PP-2: Multiple pending pairings returned ───────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pp2_multiple_pending_returned() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
svc.request_pairing("u1", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
svc.request_pairing("u2", "lark", CH_LARK, Some("Bob")).await.unwrap();
|
||||
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert_eq!(pending.len(), 2);
|
||||
}
|
||||
|
||||
// ── PP-3: Expired pairings not in pending list ─────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pp3_expired_not_in_pending() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
|
||||
// Insert already-expired code directly
|
||||
let expired_row = PairingCodeRow {
|
||||
code: "000001".into(),
|
||||
platform_user_id: "u2".into(),
|
||||
platform_type: "lark".into(),
|
||||
channel_id: None,
|
||||
display_name: None,
|
||||
requested_at: 1000,
|
||||
expires_at: 1001,
|
||||
status: "pending".into(),
|
||||
};
|
||||
repo.create_pairing(&expired_row).await.unwrap();
|
||||
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].platform_user_id, "u1");
|
||||
}
|
||||
|
||||
// ── AP-1: Approve valid pairing ────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap1_approve_valid_pairing() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
// Status updated
|
||||
let row = repo.get_pairing_by_code(&code).await.unwrap().unwrap();
|
||||
assert_eq!(row.status, "approved");
|
||||
}
|
||||
|
||||
// ── AP-2: Approved user appears in authorized list (DC-2) ──────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap2_dc2_approved_user_in_authorized_list() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let users = repo.get_all_users().await.unwrap();
|
||||
assert_eq!(users.len(), 1);
|
||||
assert_eq!(users[0].platform_user_id, "tg_42");
|
||||
assert_eq!(users[0].platform_type, "telegram");
|
||||
assert_eq!(users[0].display_name.as_deref(), Some("Alice"));
|
||||
}
|
||||
|
||||
// ── AP-3: Approve nonexistent code ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap3_approve_nonexistent_code() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let err = svc.approve_pairing("000000").await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingNotFound(_)));
|
||||
}
|
||||
|
||||
// ── AP-4: Approve expired code ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap4_approve_expired_code() {
|
||||
let (_svc, repo, bc) = setup().await;
|
||||
let svc = PairingService::new(repo.clone(), bc.clone());
|
||||
|
||||
let expired_row = PairingCodeRow {
|
||||
code: "999999".into(),
|
||||
platform_user_id: "u1".into(),
|
||||
platform_type: "telegram".into(),
|
||||
channel_id: None,
|
||||
display_name: None,
|
||||
requested_at: 1000,
|
||||
expires_at: 1001,
|
||||
status: "pending".into(),
|
||||
};
|
||||
repo.create_pairing(&expired_row).await.unwrap();
|
||||
|
||||
let err = svc.approve_pairing("999999").await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingExpired(_)));
|
||||
}
|
||||
|
||||
// ── AP-5: Double approve returns already processed ─────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap5_double_approve_returns_already_processed() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let err = svc.approve_pairing(&code).await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingAlreadyProcessed(_)));
|
||||
}
|
||||
|
||||
// ── AP-6: Missing code field (validated by DTO layer, but test via service)
|
||||
|
||||
#[tokio::test]
|
||||
async fn ap6_empty_code_returns_not_found() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let err = svc.approve_pairing("").await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingNotFound(_)));
|
||||
}
|
||||
|
||||
// ── RP-1: Reject valid pairing ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp1_reject_valid_pairing() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
|
||||
svc.reject_pairing(&code).await.unwrap();
|
||||
|
||||
let row = repo.get_pairing_by_code(&code).await.unwrap().unwrap();
|
||||
assert_eq!(row.status, "rejected");
|
||||
}
|
||||
|
||||
// ── RP-2: Rejected code not in pending list ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp2_rejected_not_in_pending() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
svc.reject_pairing(&code).await.unwrap();
|
||||
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
// ── RP-3: Reject nonexistent code ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp3_reject_nonexistent_code() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let err = svc.reject_pairing("000000").await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingNotFound(_)));
|
||||
}
|
||||
|
||||
// ── RP-4: Reject already approved code ─────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn rp4_reject_already_approved() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let err = svc.reject_pairing(&code).await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::PairingAlreadyProcessed(_)));
|
||||
}
|
||||
|
||||
// ── EC-1: Expired codes cleaned up ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ec1_expired_codes_cleaned_up() {
|
||||
let (_svc, repo, bc) = setup().await;
|
||||
let _svc = PairingService::new(repo.clone(), bc.clone());
|
||||
|
||||
let expired_row = PairingCodeRow {
|
||||
code: "111111".into(),
|
||||
platform_user_id: "u1".into(),
|
||||
platform_type: "telegram".into(),
|
||||
channel_id: None,
|
||||
display_name: None,
|
||||
requested_at: 1000,
|
||||
expires_at: 2000,
|
||||
status: "pending".into(),
|
||||
};
|
||||
repo.create_pairing(&expired_row).await.unwrap();
|
||||
|
||||
let count = repo.cleanup_expired_pairings(now_ms()).await.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let row = repo.get_pairing_by_code("111111").await.unwrap().unwrap();
|
||||
assert_eq!(row.status, "expired");
|
||||
}
|
||||
|
||||
// ── EC-2: Non-expired codes unaffected by cleanup ──────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ec2_non_expired_unaffected() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("u1", "telegram", CH_TG, None).await.unwrap();
|
||||
|
||||
let count = repo.cleanup_expired_pairings(now_ms()).await.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
|
||||
let row = repo.get_pairing_by_code(&code).await.unwrap().unwrap();
|
||||
assert_eq!(row.status, "pending");
|
||||
}
|
||||
|
||||
// ── DC-3: Same platform user unique constraint ─────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dc3_same_platform_user_unique() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
|
||||
// Approve first pairing
|
||||
let code1 = svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
svc.approve_pairing(&code1).await.unwrap();
|
||||
|
||||
// Second pairing for same user should fail on user creation (unique constraint)
|
||||
let code2 = svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
let result = svc.approve_pairing(&code2).await;
|
||||
// DB should reject duplicate (platform_user_id, platform_type)
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── WS-1: Pairing request broadcasts event ─────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws1_pairing_request_broadcasts_event() {
|
||||
let (svc, _repo, bc) = setup().await;
|
||||
svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
|
||||
let events = bc.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "channel.pairing-requested");
|
||||
assert_eq!(events[0].data["platform_user_id"], "tg_42");
|
||||
assert_eq!(events[0].data["platform_type"], "telegram");
|
||||
assert_eq!(events[0].data["display_name"], "Alice");
|
||||
assert!(events[0].data["code"].is_string());
|
||||
assert!(events[0].data["expires_at"].is_number());
|
||||
}
|
||||
|
||||
// ── WS-3: Approve broadcasts user-authorized event ─────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws3_approve_broadcasts_user_authorized() {
|
||||
let (svc, _repo, bc) = setup().await;
|
||||
let code = svc.request_pairing("tg_42", "telegram", CH_TG, Some("Alice")).await.unwrap();
|
||||
bc.take_events(); // clear request event
|
||||
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
let events = bc.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "channel.user-authorized");
|
||||
assert_eq!(events[0].data["platform_user_id"], "tg_42");
|
||||
assert_eq!(events[0].data["platform_type"], "telegram");
|
||||
assert_eq!(events[0].data["display_name"], "Alice");
|
||||
assert!(events[0].data["id"].is_string());
|
||||
}
|
||||
|
||||
// ── is_user_authorized ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_user_authorized_false_before_approval() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
assert!(!svc.is_user_authorized("tg_42", "telegram", CH_TG).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_user_authorized_true_after_approval() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("tg_42", "telegram", CH_TG, None).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
assert!(svc.is_user_authorized("tg_42", "telegram", CH_TG).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_user_authorized_different_platform_false() {
|
||||
let (svc, _repo, _bc) = setup().await;
|
||||
let code = svc.request_pairing("tg_42", "telegram", CH_TG, None).await.unwrap();
|
||||
svc.approve_pairing(&code).await.unwrap();
|
||||
|
||||
// Same user ID but different platform
|
||||
assert!(!svc.is_user_authorized("tg_42", "lark", CH_LARK).await.unwrap());
|
||||
}
|
||||
|
||||
// ── Two lark bots pair independently (per-bot channel isolation) ────
|
||||
//
|
||||
// Regression for the per-bot pairing scoping on this branch: pairing and
|
||||
// authorization are keyed by channel_id (the specific bot a message arrived
|
||||
// through), not just by platform_type. Two lark bots must pair entirely
|
||||
// independently — approving one must not authorize the other, and the other's
|
||||
// pending code must be untouched.
|
||||
#[tokio::test]
|
||||
async fn two_lark_bots_pair_independently() {
|
||||
let (svc, repo, _bc) = setup().await;
|
||||
|
||||
// setup() seeds CH_LARK (lark-1). Seed a second lark bot so the
|
||||
// channel_id FK (assistant_pairing_codes/assistant_users → assistant_plugins)
|
||||
// is satisfied for bot 2. Same upsert_plugin pattern setup() uses.
|
||||
repo.upsert_plugin(&ChannelPluginRow {
|
||||
id: CH_LARK2.into(),
|
||||
r#type: "lark".into(),
|
||||
name: "Lark Bot 2".into(),
|
||||
enabled: true,
|
||||
config: "{}".into(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Two distinct lark users each initiate pairing, one per bot. Distinct
|
||||
// open_ids mirror reality (Lark open_id is per-app) and keep the test
|
||||
// focused on channel isolation rather than same-user expiry behavior.
|
||||
let code1 = svc.request_pairing("ou_a", "lark", CH_LARK, Some("A")).await.unwrap();
|
||||
let code2 = svc.request_pairing("ou_b", "lark", CH_LARK2, Some("B")).await.unwrap();
|
||||
assert_ne!(code1, code2);
|
||||
|
||||
// Both codes are pending simultaneously, each carrying its own channel_id.
|
||||
let pending = svc.get_pending_pairings().await.unwrap();
|
||||
assert_eq!(pending.len(), 2, "both bots' pairings should be pending");
|
||||
let p1 = pending
|
||||
.iter()
|
||||
.find(|p| p.code == code1)
|
||||
.expect("bot 1 code pending");
|
||||
let p2 = pending
|
||||
.iter()
|
||||
.find(|p| p.code == code2)
|
||||
.expect("bot 2 code pending");
|
||||
assert_eq!(p1.channel_id.as_deref(), Some(CH_LARK));
|
||||
assert_eq!(p1.platform_user_id, "ou_a");
|
||||
assert_eq!(p2.channel_id.as_deref(), Some(CH_LARK2));
|
||||
assert_eq!(p2.platform_user_id, "ou_b");
|
||||
|
||||
// Approve only bot 1's pairing.
|
||||
svc.approve_pairing(&code1).await.unwrap();
|
||||
|
||||
// Bot 1's user is now authorized — but only on bot 1's channel.
|
||||
assert!(
|
||||
svc.is_user_authorized("ou_a", "lark", CH_LARK).await.unwrap(),
|
||||
"approved user must be authorized on bot 1"
|
||||
);
|
||||
|
||||
// Bot 2 is entirely unaffected: its user is not authorized and its code
|
||||
// is still pending.
|
||||
assert!(
|
||||
!svc.is_user_authorized("ou_b", "lark", CH_LARK2).await.unwrap(),
|
||||
"bot 2's user must NOT be authorized by bot 1's approval"
|
||||
);
|
||||
let pending_after = svc.get_pending_pairings().await.unwrap();
|
||||
assert_eq!(pending_after.len(), 1, "bot 2's pairing should remain pending");
|
||||
assert_eq!(pending_after[0].code, code2);
|
||||
assert_eq!(pending_after[0].channel_id.as_deref(), Some(CH_LARK2));
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Black-box integration tests for SessionManager and ActionExecutor.
|
||||
//!
|
||||
//! Uses real SQLite (in-memory) and mock EventBroadcaster.
|
||||
//! Covers test-plan items: GS-1, GS-2, PC-1..PC-3, RU-3.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_common::{generate_id, now_ms};
|
||||
use nomifun_db::models::{AssistantUserRow, ChannelPluginRow};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
|
||||
use nomifun_channel::action::{ActionExecutor, MessageResult};
|
||||
use nomifun_channel::channel_settings::ChannelSettingsService;
|
||||
use nomifun_channel::pairing::PairingService;
|
||||
use nomifun_channel::session::SessionManager;
|
||||
use nomifun_channel::types::{
|
||||
ActionBehavior, ActionCategory, ActionContext, MessageContentType, PluginType, UnifiedAction,
|
||||
UnifiedIncomingMessage, UnifiedMessageContent, UnifiedUser,
|
||||
};
|
||||
|
||||
// ── Test infrastructure ─────────────────────────────────────────────
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> (
|
||||
SessionManager,
|
||||
ActionExecutor,
|
||||
PairingService,
|
||||
Arc<dyn IChannelRepository>,
|
||||
) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let bc: Arc<dyn EventBroadcaster> = Arc::new(MockBroadcaster::new());
|
||||
|
||||
let session_mgr = SessionManager::new(repo.clone());
|
||||
let pairing = PairingService::new(repo.clone(), bc);
|
||||
let pairing_arc = Arc::new(PairingService::new(repo.clone(), Arc::new(MockBroadcaster::new())));
|
||||
let session_mgr_arc = Arc::new(SessionManager::new(repo.clone()));
|
||||
let pref_repo: Arc<dyn nomifun_db::IClientPreferenceRepository> =
|
||||
Arc::new(nomifun_db::SqliteClientPreferenceRepository::new(db.pool().clone()));
|
||||
let settings = Arc::new(ChannelSettingsService::new(pref_repo));
|
||||
let executor = ActionExecutor::new(pairing_arc, session_mgr_arc, settings, "gemini");
|
||||
|
||||
// Every test message arrives through the "tg-1" channel. assistant_sessions
|
||||
// now has an FK channel_id → assistant_plugins(id), so the plugin row must
|
||||
// exist before any session is created. bot_key=None avoids the
|
||||
// UNIQUE(type, bot_key) index.
|
||||
repo.upsert_plugin(&ChannelPluginRow {
|
||||
id: "tg-1".into(),
|
||||
r#type: "telegram".into(),
|
||||
name: "Test Bot".into(),
|
||||
enabled: true,
|
||||
config: "{}".into(),
|
||||
status: None,
|
||||
last_connected: None,
|
||||
companion_id: None,
|
||||
bot_key: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Keep db alive
|
||||
std::mem::forget(db);
|
||||
(session_mgr, executor, pairing, repo)
|
||||
}
|
||||
|
||||
/// Create an assistant_users record (required for FK on sessions).
|
||||
async fn create_user(repo: &Arc<dyn IChannelRepository>, platform_user_id: &str, platform_type: &str) -> String {
|
||||
let user_id = generate_id();
|
||||
let row = AssistantUserRow {
|
||||
id: user_id.clone(),
|
||||
platform_user_id: platform_user_id.to_owned(),
|
||||
platform_type: platform_type.to_owned(),
|
||||
channel_id: Some("tg-1".into()),
|
||||
display_name: Some("Test User".into()),
|
||||
authorized_at: now_ms(),
|
||||
last_active: None,
|
||||
session_id: None,
|
||||
};
|
||||
repo.create_user(&row).await.unwrap();
|
||||
user_id
|
||||
}
|
||||
|
||||
fn make_text_message(user_id: &str, chat_id: &str, text: &str) -> UnifiedIncomingMessage {
|
||||
UnifiedIncomingMessage {
|
||||
id: format!("msg_{}", now_ms()),
|
||||
platform: PluginType::Telegram,
|
||||
chat_id: chat_id.into(),
|
||||
user: UnifiedUser {
|
||||
id: user_id.into(),
|
||||
username: None,
|
||||
display_name: "Test User".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Text,
|
||||
text: text.into(),
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: now_ms(),
|
||||
reply_to_message_id: None,
|
||||
action: None,
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_action_message(
|
||||
user_id: &str,
|
||||
chat_id: &str,
|
||||
action_name: &str,
|
||||
category: ActionCategory,
|
||||
) -> UnifiedIncomingMessage {
|
||||
UnifiedIncomingMessage {
|
||||
id: format!("msg_{}", now_ms()),
|
||||
platform: PluginType::Telegram,
|
||||
chat_id: chat_id.into(),
|
||||
user: UnifiedUser {
|
||||
id: user_id.into(),
|
||||
username: None,
|
||||
display_name: "Test User".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Action,
|
||||
text: String::new(),
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: now_ms(),
|
||||
reply_to_message_id: None,
|
||||
action: Some(UnifiedAction {
|
||||
action: action_name.into(),
|
||||
category,
|
||||
params: None,
|
||||
context: ActionContext {
|
||||
platform: PluginType::Telegram,
|
||||
user_id: user_id.into(),
|
||||
chat_id: chat_id.into(),
|
||||
message_id: None,
|
||||
session_id: None,
|
||||
},
|
||||
}),
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: authorize a user via the pairing flow.
|
||||
async fn authorize_user(pairing: &PairingService, platform_user_id: &str, platform_type: &str) {
|
||||
let code = pairing
|
||||
.request_pairing(platform_user_id, platform_type, "tg-1", Some("Test"))
|
||||
.await
|
||||
.unwrap();
|
||||
pairing.approve_pairing(&code).await.unwrap();
|
||||
}
|
||||
|
||||
// ── GS-1: No active sessions returns empty ─────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gs1_no_sessions_returns_empty() {
|
||||
let (session_mgr, _, _, _) = setup().await;
|
||||
let sessions = session_mgr.get_active_sessions().await.unwrap();
|
||||
assert!(sessions.is_empty());
|
||||
}
|
||||
|
||||
// ── GS-2: Multiple active sessions returned ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn gs2_multiple_sessions_returned() {
|
||||
let (session_mgr, _, _, repo) = setup().await;
|
||||
|
||||
// Create users first (FK constraint)
|
||||
let uid1 = create_user(&repo, "p1", "telegram").await;
|
||||
let uid2 = create_user(&repo, "p2", "telegram").await;
|
||||
|
||||
session_mgr
|
||||
.get_or_create_session(&uid1, "c1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
session_mgr
|
||||
.get_or_create_session(&uid2, "c2", "tg-1", "acp", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sessions = session_mgr.get_active_sessions().await.unwrap();
|
||||
assert_eq!(sessions.len(), 2);
|
||||
|
||||
for s in &sessions {
|
||||
assert!(!s.id.is_empty());
|
||||
assert!(!s.user_id.is_empty());
|
||||
assert!(!s.agent_type.is_empty());
|
||||
assert!(s.chat_id.is_some());
|
||||
assert!(s.created_at > 0);
|
||||
assert!(s.last_activity > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── PC-1: Same user, different chatId → different sessions ─────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pc1_same_user_different_chat() {
|
||||
let (session_mgr, _, _, repo) = setup().await;
|
||||
|
||||
let uid = create_user(&repo, "p1", "telegram").await;
|
||||
|
||||
let s1 = session_mgr
|
||||
.get_or_create_session(&uid, "chatA", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let s2 = session_mgr
|
||||
.get_or_create_session(&uid, "chatB", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(s1.id, s2.id);
|
||||
assert_eq!(s1.user_id, uid);
|
||||
assert_eq!(s2.user_id, uid);
|
||||
assert_eq!(s1.chat_id.as_deref(), Some("chatA"));
|
||||
assert_eq!(s2.chat_id.as_deref(), Some("chatB"));
|
||||
}
|
||||
|
||||
// ── PC-2: Different users, same chatId → different sessions ────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pc2_different_users_same_chat() {
|
||||
let (session_mgr, _, _, repo) = setup().await;
|
||||
|
||||
let uid1 = create_user(&repo, "p1", "telegram").await;
|
||||
let uid2 = create_user(&repo, "p2", "telegram").await;
|
||||
|
||||
let s1 = session_mgr
|
||||
.get_or_create_session(&uid1, "chatA", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let s2 = session_mgr
|
||||
.get_or_create_session(&uid2, "chatA", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(s1.id, s2.id);
|
||||
}
|
||||
|
||||
// ── PC-3: Same user, same chatId → reuse session ──────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn pc3_same_user_same_chat_reuses() {
|
||||
let (session_mgr, _, _, repo) = setup().await;
|
||||
|
||||
let uid = create_user(&repo, "p1", "telegram").await;
|
||||
|
||||
let s1 = session_mgr
|
||||
.get_or_create_session(&uid, "chatA", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let s2 = session_mgr
|
||||
.get_or_create_session(&uid, "chatA", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(s1.id, s2.id);
|
||||
}
|
||||
|
||||
// ── RU-3: Revoke user clears sessions ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn ru3_revoke_clears_sessions() {
|
||||
let (session_mgr, _, _, repo) = setup().await;
|
||||
|
||||
let uid1 = create_user(&repo, "p1", "telegram").await;
|
||||
let uid2 = create_user(&repo, "p2", "telegram").await;
|
||||
|
||||
session_mgr
|
||||
.get_or_create_session(&uid1, "c1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
session_mgr
|
||||
.get_or_create_session(&uid1, "c2", "tg-1", "acp", None)
|
||||
.await
|
||||
.unwrap();
|
||||
session_mgr
|
||||
.get_or_create_session(&uid2, "c1", "tg-1", "gemini", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Cleanup user1 sessions
|
||||
session_mgr.cleanup_user_sessions(&uid1).await.unwrap();
|
||||
|
||||
let sessions = repo.get_all_sessions().await.unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].user_id, uid2);
|
||||
}
|
||||
|
||||
// ── ActionExecutor: unauthorized user gets pairing ─────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_unauthorized_triggers_pairing() {
|
||||
let (_, executor, _, _) = setup().await;
|
||||
|
||||
let msg = make_text_message("new_user", "chat1", "Hello");
|
||||
let result = executor.handle_incoming_message(&msg, "tg-1").await.unwrap();
|
||||
|
||||
match result {
|
||||
MessageResult::Action(resp) => {
|
||||
assert_eq!(resp.behavior, ActionBehavior::Send);
|
||||
let text = resp.text.unwrap();
|
||||
assert!(text.contains("pairing code"));
|
||||
assert!(resp.buttons.is_some());
|
||||
}
|
||||
_ => panic!("Expected Action (pairing) for unauthorized user"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── ActionExecutor: authorized user dispatches to agent ────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_authorized_dispatches() {
|
||||
let (_, executor, pairing, _) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
let msg = make_text_message("tg_42", "chat1", "Hello AI");
|
||||
let result = executor.handle_incoming_message(&msg, "tg-1").await.unwrap();
|
||||
|
||||
match result {
|
||||
MessageResult::Dispatched { session_id, .. } => {
|
||||
assert!(!session_id.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Dispatched for authorized user"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── ActionExecutor: help.show action ───────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_help_show() {
|
||||
let (_, executor, pairing, _) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
let msg = make_action_message("tg_42", "chat1", "help.show", ActionCategory::System);
|
||||
let result = executor.handle_incoming_message(&msg, "tg-1").await.unwrap();
|
||||
|
||||
match result {
|
||||
MessageResult::Action(resp) => {
|
||||
assert!(resp.text.is_some());
|
||||
assert!(resp.buttons.is_some());
|
||||
let buttons = resp.buttons.unwrap();
|
||||
assert!(buttons.len() >= 2);
|
||||
}
|
||||
_ => panic!("Expected Action result"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── ActionExecutor: session.new action ─────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_session_new() {
|
||||
let (_, executor, pairing, _) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
let msg = make_action_message("tg_42", "chat1", "session.new", ActionCategory::System);
|
||||
let result = executor.handle_incoming_message(&msg, "tg-1").await.unwrap();
|
||||
|
||||
match result {
|
||||
MessageResult::Action(resp) => {
|
||||
let text = resp.text.unwrap();
|
||||
assert!(text.contains("New session"));
|
||||
// With no client_preferences, defaults to "nomi"
|
||||
assert!(text.contains("nomi"));
|
||||
}
|
||||
_ => panic!("Expected Action result"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── ActionExecutor: session.new resets the session (H-2 fix) ─────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_session_new_resets_existing() {
|
||||
let (_, executor, pairing, repo) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
// Create a session by sending a text message
|
||||
let msg1 = make_text_message("tg_42", "chat1", "Hello");
|
||||
let r1 = executor.handle_incoming_message(&msg1, "tg-1").await.unwrap();
|
||||
let sid1 = match r1 {
|
||||
MessageResult::Dispatched { session_id, .. } => session_id,
|
||||
_ => panic!("Expected Dispatched"),
|
||||
};
|
||||
|
||||
// session.new should delete old and create fresh
|
||||
let new_msg = make_action_message("tg_42", "chat1", "session.new", ActionCategory::System);
|
||||
let r2 = executor.handle_incoming_message(&new_msg, "tg-1").await.unwrap();
|
||||
match r2 {
|
||||
MessageResult::Action(resp) => {
|
||||
let text = resp.text.unwrap();
|
||||
assert!(text.contains("New session"));
|
||||
}
|
||||
_ => panic!("Expected Action result"),
|
||||
}
|
||||
|
||||
// Send another text message — should get a different session ID
|
||||
let msg3 = make_text_message("tg_42", "chat1", "Hello again");
|
||||
let r3 = executor.handle_incoming_message(&msg3, "tg-1").await.unwrap();
|
||||
let sid3 = match r3 {
|
||||
MessageResult::Dispatched { session_id, .. } => session_id,
|
||||
_ => panic!("Expected Dispatched"),
|
||||
};
|
||||
|
||||
// The new session should have a different ID from the original
|
||||
assert_ne!(sid1, sid3);
|
||||
|
||||
// Only 1 session should exist for this user+chat in the DB
|
||||
let all = repo.get_all_sessions().await.unwrap();
|
||||
let user_sessions: Vec<_> = all.iter().filter(|s| s.chat_id.as_deref() == Some("chat1")).collect();
|
||||
assert_eq!(user_sessions.len(), 1);
|
||||
}
|
||||
|
||||
// ── ActionExecutor: agent.select persists agent_type (H-3 fix) ───
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_agent_select_persists() {
|
||||
let (_, executor, pairing, repo) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
// Create a session (default agent is "gemini")
|
||||
let msg1 = make_text_message("tg_42", "chat1", "Hello");
|
||||
executor.handle_incoming_message(&msg1, "tg-1").await.unwrap();
|
||||
|
||||
// Switch agent to "acp"
|
||||
let select_msg = UnifiedIncomingMessage {
|
||||
id: format!("msg_{}", now_ms()),
|
||||
platform: PluginType::Telegram,
|
||||
chat_id: "chat1".into(),
|
||||
user: UnifiedUser {
|
||||
id: "tg_42".into(),
|
||||
username: None,
|
||||
display_name: "Test User".into(),
|
||||
avatar_url: None,
|
||||
},
|
||||
content: UnifiedMessageContent {
|
||||
content_type: MessageContentType::Action,
|
||||
text: String::new(),
|
||||
attachments: None,
|
||||
},
|
||||
timestamp: now_ms(),
|
||||
reply_to_message_id: None,
|
||||
action: Some(UnifiedAction {
|
||||
action: "agent.select".into(),
|
||||
category: ActionCategory::System,
|
||||
params: Some(std::collections::HashMap::from([("agentType".into(), "acp".into())])),
|
||||
context: ActionContext {
|
||||
platform: PluginType::Telegram,
|
||||
user_id: "tg_42".into(),
|
||||
chat_id: "chat1".into(),
|
||||
message_id: None,
|
||||
session_id: None,
|
||||
},
|
||||
}),
|
||||
raw: None,
|
||||
};
|
||||
let r = executor.handle_incoming_message(&select_msg, "tg-1").await.unwrap();
|
||||
match r {
|
||||
MessageResult::Action(resp) => {
|
||||
let text = resp.text.unwrap();
|
||||
assert!(text.contains("acp"));
|
||||
}
|
||||
_ => panic!("Expected Action result"),
|
||||
}
|
||||
|
||||
// Verify the session's agent_type in DB
|
||||
let all = repo.get_all_sessions().await.unwrap();
|
||||
let session = all
|
||||
.iter()
|
||||
.find(|s| s.chat_id.as_deref() == Some("chat1"))
|
||||
.expect("session should exist");
|
||||
assert_eq!(session.agent_type, "acp");
|
||||
}
|
||||
|
||||
// ── ActionExecutor: session isolation across messages ───────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_session_isolation() {
|
||||
let (_, executor, pairing, _) = setup().await;
|
||||
|
||||
authorize_user(&pairing, "tg_42", "telegram").await;
|
||||
|
||||
// Send messages in two different chats
|
||||
let msg1 = make_text_message("tg_42", "chatA", "Hello 1");
|
||||
let msg2 = make_text_message("tg_42", "chatB", "Hello 2");
|
||||
|
||||
let r1 = executor.handle_incoming_message(&msg1, "tg-1").await.unwrap();
|
||||
let r2 = executor.handle_incoming_message(&msg2, "tg-1").await.unwrap();
|
||||
|
||||
let sid1 = match r1 {
|
||||
MessageResult::Dispatched { session_id, .. } => session_id,
|
||||
_ => panic!("Expected Dispatched"),
|
||||
};
|
||||
let sid2 = match r2 {
|
||||
MessageResult::Dispatched { session_id, .. } => session_id,
|
||||
_ => panic!("Expected Dispatched"),
|
||||
};
|
||||
|
||||
// Different chats → different sessions
|
||||
assert_ne!(sid1, sid2);
|
||||
|
||||
// Same chat again → reuse
|
||||
let msg3 = make_text_message("tg_42", "chatA", "Hello 3");
|
||||
let r3 = executor.handle_incoming_message(&msg3, "tg-1").await.unwrap();
|
||||
let sid3 = match r3 {
|
||||
MessageResult::Dispatched { session_id, .. } => session_id,
|
||||
_ => panic!("Expected Dispatched"),
|
||||
};
|
||||
assert_eq!(sid1, sid3);
|
||||
}
|
||||
|
||||
// Note: bind_conversation FK-constrained persistence is tested in
|
||||
// nomifun-db sqlite_channel.rs::update_session_conversation_persists.
|
||||
// Unit tests for the SessionManager layer are in session.rs.
|
||||
@@ -0,0 +1,538 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_ai_agent::AgentStreamEvent;
|
||||
use nomifun_ai_agent::protocol::events::{
|
||||
AcpPermissionEventData, AcpPermissionOptionData, AcpPermissionOptionKind, AcpPermissionRequestData,
|
||||
AcpPermissionToolCall, ErrorEventData, FinishEventData, TextEventData, ToolCallEventData, ToolCallStatus,
|
||||
};
|
||||
use nomifun_channel::pending_decision::PendingDecisionStore;
|
||||
use nomifun_channel::stream_relay::{ChannelSender, ChannelStreamRelay, MessageRecorder, RelayConfig};
|
||||
use nomifun_channel::types::{ParseMode, PluginType};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Builds a relay with a fresh (unshared) pending-decision store. Tests that
|
||||
/// need to inspect the store pass their own via [`relay_with_store`].
|
||||
fn relay(config: RelayConfig, sender: Arc<dyn ChannelSender>) -> ChannelStreamRelay {
|
||||
ChannelStreamRelay::new(config, sender, PendingDecisionStore::new())
|
||||
}
|
||||
|
||||
/// Builds a relay sharing the caller's pending-decision store.
|
||||
fn relay_with_store(
|
||||
config: RelayConfig,
|
||||
sender: Arc<dyn ChannelSender>,
|
||||
store: Arc<PendingDecisionStore>,
|
||||
) -> ChannelStreamRelay {
|
||||
ChannelStreamRelay::new(config, sender, store)
|
||||
}
|
||||
|
||||
// ── RelayConfig construction ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn relay_config_fields() {
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "123".into(),
|
||||
throttle_ms: 500,
|
||||
conversation_id: "conv-1".into(),
|
||||
};
|
||||
assert_eq!(config.throttle_ms, 500);
|
||||
assert_eq!(config.plugin_id, "telegram");
|
||||
assert_eq!(config.conversation_id, "conv-1");
|
||||
}
|
||||
|
||||
// ── Full relay run with mock ChannelSender ───────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_sends_thinking_then_final_message() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "Hello".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: " World".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData { session_id: None, stop_reason: None }))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let sends = recorder.take_sends();
|
||||
assert!(!sends.is_empty());
|
||||
assert!(sends[0].text.as_deref().unwrap().contains("Thinking"));
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
let last = edits.last().unwrap();
|
||||
assert!(last.text.as_deref().unwrap().contains("Hello World"));
|
||||
assert!(last.buttons.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_handles_error_event() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Error(ErrorEventData::legacy("timeout", None)))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
let last = edits.last().unwrap();
|
||||
assert!(last.text.as_deref().unwrap().contains("timeout"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weixin_flushes_pending_text_before_tool_call() {
|
||||
// Port of Nomi TS fix `406a62665` to the backend relay layer. On
|
||||
// WeChat, in-place editing is not supported, so a tool-status update
|
||||
// would otherwise overwrite any assistant text the user hasn't yet
|
||||
// seen. The relay should flush buffered text as an independent
|
||||
// send_message before rendering the tool-call indicator, matching the
|
||||
// TS WeixinPlugin.sendTextNow draft-flush behaviour.
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Weixin,
|
||||
plugin_id: "weixin".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000, // large throttle so the mid-stream edit doesn't fire
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "Here is the plan:".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::ToolCall(ToolCallEventData {
|
||||
call_id: "call-1".into(),
|
||||
name: "read_file".into(),
|
||||
args: serde_json::Value::Null,
|
||||
status: ToolCallStatus::Running,
|
||||
description: None,
|
||||
input: None,
|
||||
output: None,
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData { session_id: None, stop_reason: None }))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let sends = recorder.take_sends();
|
||||
// WeChat relay does NOT send a "Thinking..." placeholder. The first
|
||||
// send_message should be the flushed assistant text triggered by the
|
||||
// ToolCall event.
|
||||
assert!(!sends.is_empty(), "expected flush send_message, got {:?}", sends);
|
||||
let flushed = &sends[0];
|
||||
assert!(
|
||||
flushed.text.as_deref().unwrap().contains("Here is the plan"),
|
||||
"expected flushed text, got {:?}",
|
||||
flushed.text
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_does_not_flush_text_before_tool_call() {
|
||||
// Non-WeChat platforms support edit_message, so the TS flush rule does
|
||||
// not apply — the relay should continue to edit the placeholder in
|
||||
// place without issuing a new send_message for the buffered text.
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "Here is the plan:".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::ToolCall(ToolCallEventData {
|
||||
call_id: "call-1".into(),
|
||||
name: "read_file".into(),
|
||||
args: serde_json::Value::Null,
|
||||
status: ToolCallStatus::Running,
|
||||
description: None,
|
||||
input: None,
|
||||
output: None,
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData { session_id: None, stop_reason: None }))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let sends = recorder.take_sends();
|
||||
// Only the "Thinking..." placeholder is sent — no flush on non-WeChat.
|
||||
assert_eq!(sends.len(), 1, "unexpected extra sends: {:?}", sends);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weixin_skips_flush_when_buffer_is_empty() {
|
||||
// Tool call before any assistant text should not trigger a blank flush.
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Weixin,
|
||||
plugin_id: "weixin".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::ToolCall(ToolCallEventData {
|
||||
call_id: "call-1".into(),
|
||||
name: "read_file".into(),
|
||||
args: serde_json::Value::Null,
|
||||
status: ToolCallStatus::Running,
|
||||
description: None,
|
||||
input: None,
|
||||
output: None,
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData { session_id: None, stop_reason: None }))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let sends = recorder.take_sends();
|
||||
// WeChat relay does NOT send Thinking placeholder, and with no buffered
|
||||
// text there should be zero sends (no flush needed).
|
||||
assert_eq!(sends.len(), 0, "no sends expected for empty buffer: {:?}", sends);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_handles_channel_closed() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "partial".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
drop(event_tx);
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
assert!(!edits.is_empty());
|
||||
assert!(edits.last().unwrap().text.as_deref().unwrap().contains("partial"));
|
||||
}
|
||||
|
||||
// ── Telegram parse mode (HTML formatter output must be declared) ─────
|
||||
|
||||
/// The formatter emits HTML for Telegram; streaming edits and the final
|
||||
/// message must carry `parse_mode: HTML` or the tags render literally.
|
||||
#[tokio::test]
|
||||
async fn telegram_streaming_and_final_messages_use_html_parse_mode() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 0, // edit on every chunk so the streaming path is exercised
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "**bold** & <raw>".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData {
|
||||
session_id: None,
|
||||
stop_reason: None,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
// The "Thinking..." placeholder is plain text — no parse mode.
|
||||
let sends = recorder.take_sends();
|
||||
assert_eq!(sends.len(), 1);
|
||||
assert_eq!(sends[0].parse_mode, None);
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
assert!(!edits.is_empty());
|
||||
for edit in &edits {
|
||||
assert_eq!(
|
||||
edit.parse_mode,
|
||||
Some(ParseMode::HTML),
|
||||
"telegram edit must declare HTML parse mode: {edit:?}"
|
||||
);
|
||||
}
|
||||
// Formatter output: markdown converted to tags, source &/< escaped.
|
||||
let final_text = edits.last().unwrap().text.as_deref().unwrap();
|
||||
assert!(final_text.contains("<b>bold</b>"), "got: {final_text}");
|
||||
assert!(final_text.contains("&"), "got: {final_text}");
|
||||
assert!(final_text.contains("<raw>"), "got: {final_text}");
|
||||
}
|
||||
|
||||
/// Tool-status edits show raw (unescaped) agent output, so they must stay
|
||||
/// plain text even on Telegram.
|
||||
#[tokio::test]
|
||||
async fn telegram_tool_call_edit_stays_plain_text() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::ToolCall(ToolCallEventData {
|
||||
call_id: "call-1".into(),
|
||||
name: "read_file".into(),
|
||||
args: serde_json::Value::Null,
|
||||
status: ToolCallStatus::Running,
|
||||
description: None,
|
||||
input: None,
|
||||
output: None,
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData {
|
||||
session_id: None,
|
||||
stop_reason: None,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
let tool_edit = edits
|
||||
.iter()
|
||||
.find(|e| e.text.as_deref().is_some_and(|t| t.contains("read_file")))
|
||||
.expect("tool-status edit");
|
||||
assert_eq!(tool_edit.parse_mode, None);
|
||||
}
|
||||
|
||||
/// Non-Telegram platforms receive markdown/plain text — parse mode stays
|
||||
/// unset for them.
|
||||
#[tokio::test]
|
||||
async fn lark_messages_have_no_parse_mode() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Lark,
|
||||
plugin_id: "lark".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 0,
|
||||
conversation_id: "conv-test".into(),
|
||||
};
|
||||
let relay = relay(config, recorder.clone());
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Text(TextEventData {
|
||||
content: "**bold** text".into(),
|
||||
}))
|
||||
.unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData {
|
||||
session_id: None,
|
||||
stop_reason: None,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let edits = recorder.take_edits();
|
||||
assert!(!edits.is_empty());
|
||||
for edit in &edits {
|
||||
assert_eq!(edit.parse_mode, None, "lark edits must not set parse mode: {edit:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Decision relay (Bug 1, Case A) ───────────────────────────────────
|
||||
|
||||
/// Builds an ACP permission-request event with two options.
|
||||
fn acp_decision_event(call_id: &str, title: &str) -> AgentStreamEvent {
|
||||
AgentStreamEvent::AcpPermission(AcpPermissionEventData::Request(AcpPermissionRequestData {
|
||||
session_id: "s1".into(),
|
||||
tool_call: AcpPermissionToolCall {
|
||||
tool_call_id: call_id.into(),
|
||||
status: None,
|
||||
title: Some(title.into()),
|
||||
kind: None,
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
content: None,
|
||||
locations: None,
|
||||
meta: None,
|
||||
},
|
||||
options: vec![
|
||||
AcpPermissionOptionData {
|
||||
option_id: "allow".into(),
|
||||
name: "Allow once".into(),
|
||||
kind: AcpPermissionOptionKind::AllowOnce,
|
||||
meta: None,
|
||||
},
|
||||
AcpPermissionOptionData {
|
||||
option_id: "reject".into(),
|
||||
name: "Reject".into(),
|
||||
kind: AcpPermissionOptionKind::RejectOnce,
|
||||
meta: None,
|
||||
},
|
||||
],
|
||||
meta: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A relayed decision is recorded in the shared store and forwarded as a
|
||||
/// numbered text message (a new send, not an edit of the thinking card).
|
||||
#[tokio::test]
|
||||
async fn relay_forwards_decision_and_records_pending() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
let store = PendingDecisionStore::new();
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Telegram,
|
||||
plugin_id: "telegram".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000,
|
||||
conversation_id: "conv-dec".into(),
|
||||
};
|
||||
let relay = relay_with_store(config, recorder.clone(), Arc::clone(&store));
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx.send(acp_decision_event("call-42", "Run rm -rf?")).unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData {
|
||||
session_id: None,
|
||||
stop_reason: None,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
// A numbered decision message was sent as a new message.
|
||||
let sends = recorder.take_sends();
|
||||
let decision = sends
|
||||
.iter()
|
||||
.find(|m| m.text.as_deref().is_some_and(|t| t.contains("需要你的决策")))
|
||||
.expect("a numbered decision message must be sent");
|
||||
let text = decision.text.as_deref().unwrap();
|
||||
assert!(text.contains("Run rm -rf?"), "prompt present: {text}");
|
||||
assert!(text.contains("1. Allow once"), "first option numbered: {text}");
|
||||
assert!(text.contains("2. Reject"), "second option numbered: {text}");
|
||||
assert!(decision.buttons.is_none(), "decision is plain text, no buttons");
|
||||
|
||||
// The pending decision is recorded against the conversation.
|
||||
let pending = store.peek("conv-dec").expect("decision recorded in store");
|
||||
assert_eq!(pending.call_id, "call-42");
|
||||
assert_eq!(pending.options.len(), 2);
|
||||
assert_eq!(pending.options[0].option_id, "allow");
|
||||
assert_eq!(pending.options[1].option_id, "reject");
|
||||
}
|
||||
|
||||
/// WeChat (no edit support) also forwards the decision as a send_message.
|
||||
#[tokio::test]
|
||||
async fn weixin_relay_forwards_decision() {
|
||||
let (event_tx, _) = broadcast::channel::<AgentStreamEvent>(64);
|
||||
let recorder = Arc::new(MessageRecorder::new());
|
||||
let store = PendingDecisionStore::new();
|
||||
|
||||
let config = RelayConfig {
|
||||
platform: PluginType::Weixin,
|
||||
plugin_id: "weixin".into(),
|
||||
chat_id: "chat_1".into(),
|
||||
throttle_ms: 10_000,
|
||||
conversation_id: "conv-wx".into(),
|
||||
};
|
||||
let relay = relay_with_store(config, recorder.clone(), Arc::clone(&store));
|
||||
let rx = event_tx.subscribe();
|
||||
|
||||
event_tx.send(acp_decision_event("call-wx", "Proceed?")).unwrap();
|
||||
event_tx
|
||||
.send(AgentStreamEvent::Finish(FinishEventData {
|
||||
session_id: None,
|
||||
stop_reason: None,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
relay.run(rx).await;
|
||||
|
||||
let sends = recorder.take_sends();
|
||||
assert!(
|
||||
sends
|
||||
.iter()
|
||||
.any(|m| m.text.as_deref().is_some_and(|t| t.contains("需要你的决策"))),
|
||||
"weixin relay must forward the decision: {sends:?}"
|
||||
);
|
||||
assert!(store.peek("conv-wx").is_some(), "decision recorded for weixin");
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Black-box integration tests for the Telegram plugin.
|
||||
//!
|
||||
//! Tests the TelegramPlugin through the public ChannelPlugin trait interface
|
||||
//! and ChannelManager integration.
|
||||
//!
|
||||
//! Covers test-plan items: TP-2, TP-5, EP-5, DP-2.
|
||||
//!
|
||||
//! NOTE: Tests that require a live Telegram API (TP-1, EP-1) are not included
|
||||
//! here — they would need a real bot token. The unit tests within the crate
|
||||
//! cover pure function logic (content extraction, callback parsing, message
|
||||
//! truncation, backoff, markup building, etc.).
|
||||
|
||||
#[cfg(feature = "telegram")]
|
||||
mod telegram_tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::manager::{ChannelManager, EnableChannelSpec, PluginFactory};
|
||||
use nomifun_channel::plugin::ChannelPlugin;
|
||||
use nomifun_channel::plugins::telegram::TelegramPlugin;
|
||||
use nomifun_channel::types::{PluginConfig, PluginCredentials, PluginStatus, PluginType};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// -- Test infrastructure ------------------------------------------------
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_encryption_key() -> [u8; 32] {
|
||||
[0x42u8; 32]
|
||||
}
|
||||
|
||||
async fn setup() -> (ChannelManager, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(MockBroadcaster::new());
|
||||
let (message_tx, _message_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, _confirm_rx) = mpsc::channel(16);
|
||||
|
||||
let manager = ChannelManager::new(
|
||||
repo.clone(),
|
||||
broadcaster.clone(),
|
||||
make_encryption_key(),
|
||||
message_tx,
|
||||
confirm_tx,
|
||||
);
|
||||
|
||||
// Keep db alive — test process exits anyway
|
||||
std::mem::forget(db);
|
||||
|
||||
(manager, repo, broadcaster)
|
||||
}
|
||||
|
||||
fn telegram_factory() -> PluginFactory {
|
||||
Box::new(|pt| {
|
||||
if pt == PluginType::Telegram {
|
||||
Some(Box::new(TelegramPlugin::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_plugin_config(token: Option<&str>) -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
token: token.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_config_value(token: Option<&str>) -> serde_json::Value {
|
||||
let mut creds = serde_json::Map::new();
|
||||
if let Some(t) = token {
|
||||
creds.insert("token".into(), serde_json::Value::String(t.into()));
|
||||
}
|
||||
serde_json::json!({
|
||||
"credentials": creds,
|
||||
"config": { "mode": "polling" }
|
||||
})
|
||||
}
|
||||
|
||||
// -- Plugin construction ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn telegram_plugin_initial_state() {
|
||||
let plugin = TelegramPlugin::new();
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
assert!(plugin.bot_info().is_none());
|
||||
assert!(plugin.last_error().is_none());
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Telegram);
|
||||
assert_eq!(plugin.active_user_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_plugin_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<TelegramPlugin>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_plugin_as_trait_object() {
|
||||
let plugin = TelegramPlugin::new();
|
||||
let boxed: Box<dyn ChannelPlugin> = Box::new(plugin);
|
||||
assert_eq!(boxed.plugin_type(), PluginType::Telegram);
|
||||
assert_eq!(boxed.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
// -- Factory registration -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn factory_creates_telegram_plugin() {
|
||||
let factory = telegram_factory();
|
||||
let plugin = factory(PluginType::Telegram);
|
||||
assert!(plugin.is_some());
|
||||
let plugin = plugin.unwrap();
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Telegram);
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_returns_none_for_other_types() {
|
||||
let factory = telegram_factory();
|
||||
assert!(factory(PluginType::Lark).is_none());
|
||||
assert!(factory(PluginType::Dingtalk).is_none());
|
||||
assert!(factory(PluginType::Weixin).is_none());
|
||||
}
|
||||
|
||||
// -- TP-2: Test invalid token -------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_invalid_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
|
||||
// Invalid token → getMe will fail with HTTP or API error
|
||||
let config = make_plugin_config(Some("invalid-token-12345"));
|
||||
let result = manager.test_plugin("telegram", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- TP-5: Missing token ------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
|
||||
let config = make_plugin_config(None);
|
||||
let result = manager.test_plugin("telegram", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("token"),
|
||||
"Error should mention token: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_empty_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
|
||||
let config = make_plugin_config(Some(""));
|
||||
let result = manager.test_plugin("telegram", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- EP-5: Invalid plugin type ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_invalid_plugin_type_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
|
||||
let config = make_config_value(Some("bot:123"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("nonexistent"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Enable with invalid token ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_plugin_invalid_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
|
||||
let config = make_config_value(Some("bad-token"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("telegram"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- Disable plugin with no DB row returns error -----------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_without_db_row_returns_error() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
// Plugin was never enabled (no DB row), so update_plugin_status fails
|
||||
let result = manager.disable_plugin("telegram").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- PS-1: Empty plugin status ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_plugin_status_empty() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let statuses = manager.get_plugin_status().await.unwrap();
|
||||
assert!(statuses.is_empty());
|
||||
}
|
||||
|
||||
// -- Restore with nothing stored ----------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_plugins_none_stored() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = telegram_factory();
|
||||
let result = manager.restore_plugins(&factory).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(manager.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// -- Plugin running check -----------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_plugin_running_false_when_not_enabled() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
assert!(!manager.is_plugin_running("telegram"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Black-box integration tests for the WeChat (iLink Bot) plugin.
|
||||
//!
|
||||
//! Tests the WeixinPlugin through the public ChannelPlugin trait interface
|
||||
//! and ChannelManager integration.
|
||||
//!
|
||||
//! Covers test-plan items: TP-2, TP-5, EP-5, DP-2, WL-1 (event structure).
|
||||
//!
|
||||
//! NOTE: Tests that require a live iLink Bot API (TP-1, EP-1, WL-1 full flow)
|
||||
//! are not included — they need a real bot token + account. Unit tests within
|
||||
//! the crate cover pure function logic (content extraction, message types,
|
||||
//! login event serialization, etc.).
|
||||
|
||||
#[cfg(feature = "weixin")]
|
||||
mod weixin_tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_channel::manager::{ChannelManager, EnableChannelSpec, PluginFactory};
|
||||
use nomifun_channel::plugin::ChannelPlugin;
|
||||
use nomifun_channel::plugins::weixin::WeixinPlugin;
|
||||
use nomifun_channel::types::{PluginConfig, PluginCredentials, PluginStatus, PluginType};
|
||||
use nomifun_db::{IChannelRepository, SqliteChannelRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
// -- Test infrastructure ------------------------------------------------
|
||||
|
||||
struct MockBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl MockBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for MockBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_encryption_key() -> [u8; 32] {
|
||||
[0x42u8; 32]
|
||||
}
|
||||
|
||||
async fn setup() -> (ChannelManager, Arc<dyn IChannelRepository>, Arc<MockBroadcaster>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IChannelRepository> = Arc::new(SqliteChannelRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(MockBroadcaster::new());
|
||||
let (message_tx, _message_rx) = mpsc::channel(16);
|
||||
let (confirm_tx, _confirm_rx) = mpsc::channel(16);
|
||||
|
||||
let manager = ChannelManager::new(
|
||||
repo.clone(),
|
||||
broadcaster.clone(),
|
||||
make_encryption_key(),
|
||||
message_tx,
|
||||
confirm_tx,
|
||||
);
|
||||
|
||||
// Keep db alive — test process exits anyway
|
||||
std::mem::forget(db);
|
||||
|
||||
(manager, repo, broadcaster)
|
||||
}
|
||||
|
||||
fn weixin_factory() -> PluginFactory {
|
||||
Box::new(|pt| {
|
||||
if pt == PluginType::Weixin {
|
||||
Some(Box::new(WeixinPlugin::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_plugin_config(bot_token: Option<&str>, account_id: Option<&str>) -> PluginConfig {
|
||||
PluginConfig {
|
||||
credentials: PluginCredentials {
|
||||
account_id: account_id.map(String::from),
|
||||
bot_token: bot_token.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_config_value(bot_token: Option<&str>, account_id: Option<&str>) -> serde_json::Value {
|
||||
let mut creds = serde_json::Map::new();
|
||||
if let Some(t) = bot_token {
|
||||
creds.insert("botToken".into(), serde_json::Value::String(t.into()));
|
||||
}
|
||||
if let Some(a) = account_id {
|
||||
creds.insert("accountId".into(), serde_json::Value::String(a.into()));
|
||||
}
|
||||
serde_json::json!({
|
||||
"credentials": creds,
|
||||
"config": { "mode": "polling" }
|
||||
})
|
||||
}
|
||||
|
||||
// -- Plugin construction ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn weixin_plugin_initial_state() {
|
||||
let plugin = WeixinPlugin::new();
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
assert!(plugin.bot_info().is_none());
|
||||
assert!(plugin.last_error().is_none());
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Weixin);
|
||||
assert_eq!(plugin.active_user_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weixin_plugin_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<WeixinPlugin>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weixin_plugin_as_trait_object() {
|
||||
let plugin = WeixinPlugin::new();
|
||||
let boxed: Box<dyn ChannelPlugin> = Box::new(plugin);
|
||||
assert_eq!(boxed.plugin_type(), PluginType::Weixin);
|
||||
assert_eq!(boxed.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
// -- Factory registration -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn factory_creates_weixin_plugin() {
|
||||
let factory = weixin_factory();
|
||||
let plugin = factory(PluginType::Weixin);
|
||||
assert!(plugin.is_some());
|
||||
let plugin = plugin.unwrap();
|
||||
assert_eq!(plugin.plugin_type(), PluginType::Weixin);
|
||||
assert_eq!(plugin.status(), PluginStatus::Created);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_returns_none_for_other_types() {
|
||||
let factory = weixin_factory();
|
||||
assert!(factory(PluginType::Telegram).is_none());
|
||||
assert!(factory(PluginType::Lark).is_none());
|
||||
assert!(factory(PluginType::Dingtalk).is_none());
|
||||
}
|
||||
|
||||
// -- TP-5: Missing credentials ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_bot_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
|
||||
let config = make_plugin_config(None, Some("acc_1"));
|
||||
let result = manager.test_plugin("weixin", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("bot_token") || err_msg.to_lowercase().contains("bottoken"),
|
||||
"Error should mention bot_token: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_missing_account_id_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
|
||||
let config = make_plugin_config(Some("tok_1"), None);
|
||||
let result = manager.test_plugin("weixin", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.to_lowercase().contains("account_id") || err_msg.to_lowercase().contains("accountid"),
|
||||
"Error should mention account_id: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_empty_bot_token_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
|
||||
let config = make_plugin_config(Some(""), Some("acc_1"));
|
||||
let result = manager.test_plugin("weixin", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plugin_empty_account_id_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
|
||||
let config = make_plugin_config(Some("tok_1"), Some(""));
|
||||
let result = manager.test_plugin("weixin", config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- EP-5: Invalid plugin type ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_invalid_plugin_type_fails() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
|
||||
let config = make_config_value(Some("tok_1"), Some("acc_1"));
|
||||
let result = manager.enable_plugin(&EnableChannelSpec::legacy("nonexistent"), &config, &factory).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- DP-2: Disable without enable (idempotent/error) -------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_without_db_row_returns_error() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let result = manager.disable_plugin("weixin").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- PS-1: Empty plugin status ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_plugin_status_empty() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let statuses = manager.get_plugin_status().await.unwrap();
|
||||
assert!(statuses.is_empty());
|
||||
}
|
||||
|
||||
// -- Restore with nothing stored ----------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_plugins_none_stored() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
let factory = weixin_factory();
|
||||
let result = manager.restore_plugins(&factory).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(manager.active_plugin_count(), 0);
|
||||
}
|
||||
|
||||
// -- Plugin running check -----------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_plugin_running_false_when_not_enabled() {
|
||||
let (manager, _repo, _bc) = setup().await;
|
||||
assert!(!manager.is_plugin_running("weixin"));
|
||||
}
|
||||
|
||||
// -- Login event serialization ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn login_event_qr_serializes_correctly() {
|
||||
use nomifun_channel::plugins::weixin::weixin_login_stream;
|
||||
|
||||
// Just verify the public function is accessible and returns a receiver
|
||||
// (we cannot test the full flow without a live API, but we verify the
|
||||
// type is exported correctly).
|
||||
let _fn_ref: fn() -> tokio::sync::mpsc::Receiver<_> = weixin_login_stream;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user