Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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("&lt;script&gt;"), "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 = "&amp; &lt;tag&gt;";
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("&lt;b&gt;"), "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("&amp;"), "got: {final_text}");
assert!(final_text.contains("&lt;raw&gt;"), "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;
}
}