chore: upgrade Rust edition 2018 2021
- Cargo.toml: edition 2021 - plugin-sdk/Cargo.toml: edition 2021 - plugins/example-plugin/Cargo.toml: edition 2021 Rust 2021 edition 带来更好的闭包捕获规则、IntoIterator for arrays 等改进。
This commit is contained in:
806
tests/m1_2_http.rs
Normal file
806
tests/m1_2_http.rs
Normal file
@@ -0,0 +1,806 @@
|
||||
//! M1.2 集成测试 — HTTP API 路由级验证
|
||||
//!
|
||||
//! 测试范围:播放控制、配置管理、播放列表、插件管理 API 闭环、错误场景。
|
||||
//! 设计原则:不依赖真实 OpenCV/硬件,使用 ServiceManager + RecordingPlugin 构建 fake 状态。
|
||||
//!
|
||||
//! ## 插件管理 API 闭环结论(M1.2 缺陷对齐)
|
||||
//!
|
||||
//! 经过代码审查,插件管理 API 的 Custom 消息已在 ServiceManager 中完整处理:
|
||||
//! - `plugin_enable` / `plugin_disable` → `set_plugin_enabled()` → `broadcast_plugin_states()`
|
||||
//! - `plugin_rollback` / `plugin_switch` / `plugin_install` / `plugin_check_updates` 已注册但未完整实现业务逻辑
|
||||
//! - HTTP routes 的 `plugin_enable_route`/`plugin_disable_route` 发送 `Message::Custom{kind:"plugin_enable",...}`
|
||||
//! 到 `Destination::Manager`,ServiceManager 能够接收并处理。
|
||||
//! - `/api/plugins` 通过 `HttpState::plugin_states()` 读取状态,状态由
|
||||
//! `Message::Custom{kind:"plugin_states",...}` 广播更新,ServiceManager 在每次
|
||||
//! enable/disable 后调用 `broadcast_plugin_states()` 发出该消息。
|
||||
//! - **结论**:enable/disable 命令闭环已修复(CLAUDE.md #25 已修复项),基本链路可工作。
|
||||
//! plugin_rollback/switch/install/check_updates 仅发送消息,ServiceManager 侧仅打印日志,
|
||||
//! 尚无完整业务逻辑,属 M1.2 已知待实现项。
|
||||
|
||||
use anyhow::Result;
|
||||
use showen_v2::core::config::AppConfig;
|
||||
use showen_v2::core::message::{Destination, Envelope, Message, PlayerStatusData};
|
||||
use showen_v2::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
|
||||
use showen_v2::core::service_manager::ServiceManager;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// ── 测试工具函数 ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn unique_test_dir(name: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time should be after unix epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!(
|
||||
"showen_m1_2_http_{name}_{}_{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
))
|
||||
}
|
||||
|
||||
/// 生成标准测试配置 JSON,remote_control 默认关闭以避免端口竞争。
|
||||
fn config_json_with_playlist(window_title: &str, playlist_len: usize) -> String {
|
||||
let playlist_items: Vec<String> = (0..playlist_len)
|
||||
.map(|i| {
|
||||
format!(
|
||||
r#"{{"id": "video-{i}", "path": "video{i}.mp4"}}"#,
|
||||
i = i
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let playlist = playlist_items.join(",");
|
||||
|
||||
format!(
|
||||
r#"{{
|
||||
"display": {{
|
||||
"fullscreen": false,
|
||||
"window_title": "{window_title}",
|
||||
"rotation": 0,
|
||||
"flip_horizontal": false,
|
||||
"flip_vertical": false,
|
||||
"perspective_correction": {{
|
||||
"enabled": false,
|
||||
"points": []
|
||||
}}
|
||||
}},
|
||||
"playlist": [{playlist}],
|
||||
"transition": {{
|
||||
"enabled": false,
|
||||
"type": "none",
|
||||
"duration": 0.0
|
||||
}},
|
||||
"playback": {{
|
||||
"loop_playlist": true,
|
||||
"auto_start": false
|
||||
}},
|
||||
"scenes": {{}},
|
||||
"remote_control": {{
|
||||
"enabled": false,
|
||||
"host": "127.0.0.1",
|
||||
"port": 18080
|
||||
}}
|
||||
}}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn write_test_config(dir: &Path, window_title: &str, playlist_len: usize) -> PathBuf {
|
||||
fs::create_dir_all(dir).expect("test dir should be created");
|
||||
let config_path = dir.join("config.json");
|
||||
fs::write(
|
||||
&config_path,
|
||||
config_json_with_playlist(window_title, playlist_len),
|
||||
)
|
||||
.expect("config should be written");
|
||||
config_path
|
||||
}
|
||||
|
||||
fn setup(name: &str, playlist_len: usize) -> (ServiceManager, Arc<Mutex<Vec<String>>>, PathBuf) {
|
||||
let dir = unique_test_dir(name);
|
||||
let config_path = write_test_config(&dir, "test-title", playlist_len);
|
||||
let config = AppConfig::from_file(&config_path).expect("test config should load");
|
||||
(
|
||||
ServiceManager::new(config),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
dir,
|
||||
)
|
||||
}
|
||||
|
||||
fn lock_events(events: &Arc<Mutex<Vec<String>>>) -> std::sync::MutexGuard<'_, Vec<String>> {
|
||||
events.lock().expect("events mutex poisoned")
|
||||
}
|
||||
|
||||
fn has_event(events: &Arc<Mutex<Vec<String>>>, expected: &str) -> bool {
|
||||
lock_events(events).iter().any(|e| e.contains(expected))
|
||||
}
|
||||
|
||||
// ── 记录型插件 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct RecordingPlugin {
|
||||
id: String,
|
||||
deps: Vec<String>,
|
||||
events: Arc<Mutex<Vec<String>>>,
|
||||
ctx: Option<PluginContext>,
|
||||
}
|
||||
|
||||
impl RecordingPlugin {
|
||||
fn new(id: &str, deps: Vec<&str>, events: Arc<Mutex<Vec<String>>>) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
deps: deps.into_iter().map(str::to_string).collect(),
|
||||
events,
|
||||
ctx: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&self, entry: impl Into<String>) {
|
||||
lock_events(&self.events).push(entry.into());
|
||||
}
|
||||
|
||||
fn sender(&self) -> Option<std::sync::mpsc::Sender<Envelope>> {
|
||||
self.ctx.as_ref().map(|c| c.tx.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for RecordingPlugin {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn info(&self) -> PluginInfo {
|
||||
PluginInfo {
|
||||
name: self.id.clone(),
|
||||
version: "test".to_string(),
|
||||
description: "http integration test plugin".to_string(),
|
||||
platform: Platform::Any,
|
||||
}
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<String> {
|
||||
self.deps.clone()
|
||||
}
|
||||
|
||||
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
||||
self.ctx = Some(ctx);
|
||||
self.record(format!("init:{}", self.id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start(&mut self) -> Result<()> {
|
||||
self.record(format!("start:{}", self.id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||
let label = match &msg {
|
||||
Message::PlayerStatus(s) => format!(
|
||||
"player_status:{}:{}:{}:{}",
|
||||
s.running, s.paused, s.current_index, s.playlist_length
|
||||
),
|
||||
Message::ConfigReloaded(c) => {
|
||||
format!("config_reloaded:{}", c.display.window_title)
|
||||
}
|
||||
Message::WifiResult(payload) => format!("wifi_result:{payload}"),
|
||||
Message::Custom { kind, payload } => format!("custom:{kind}:{payload}"),
|
||||
Message::PlayerCommand(cmd) => format!("player_cmd:{cmd:?}"),
|
||||
Message::Shutdown => "shutdown".to_string(),
|
||||
other => format!("other:{other:?}"),
|
||||
};
|
||||
self.record(format!("msg:{}:{label}", self.id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) -> Result<()> {
|
||||
self.record(format!("stop:{}", self.id));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── 测试:播放控制 — GET /api/status ─────────────────────────────────────────
|
||||
|
||||
/// GET /api/status 成功场景:HttpState 保存的 PlayerStatusData 可被读取
|
||||
/// 这里通过 PlayerStatus 消息广播到 http 插件来验证状态流。
|
||||
#[test]
|
||||
fn test_http_status_reflects_player_status_broadcast() {
|
||||
let (mut manager, events, dir) = setup("status_reflects_broadcast", 3);
|
||||
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"video",
|
||||
vec![],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"http",
|
||||
vec!["video"],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.start_all().expect("start_all should succeed");
|
||||
|
||||
let sender = manager.sender();
|
||||
// 模拟 video 插件广播 PlayerStatus
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "video".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::PlayerStatus(PlayerStatusData {
|
||||
running: true,
|
||||
paused: false,
|
||||
in_transition: false,
|
||||
current_index: 1,
|
||||
playlist_length: 3,
|
||||
current_video: Some("video1.mp4".to_string()),
|
||||
}),
|
||||
})
|
||||
.expect("player status should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
.expect("shutdown should send");
|
||||
|
||||
manager.run().expect("run should succeed");
|
||||
|
||||
// http 和 video 都收到了 PlayerStatus 广播
|
||||
assert!(
|
||||
has_event(&events, "msg:http:player_status:true:false:1:3"),
|
||||
"http plugin should receive PlayerStatus broadcast"
|
||||
);
|
||||
assert!(
|
||||
has_event(&events, "msg:video:player_status:true:false:1:3"),
|
||||
"video plugin should also receive PlayerStatus broadcast"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||
}
|
||||
|
||||
// ── 测试:播放控制 — 命令发送链路 ──────────────────────────────────────────────
|
||||
|
||||
/// POST /api/play、/api/pause、/api/next 路由向 video 插件发送 PlayerCommand。
|
||||
/// 这里验证消息从 http 直接发往 video 插件的路由是否正确。
|
||||
#[test]
|
||||
fn test_http_play_command_routes_to_video_plugin() {
|
||||
use showen_v2::core::message::PlayerCommand;
|
||||
|
||||
let (mut manager, events, dir) = setup("play_cmd_route", 2);
|
||||
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"video",
|
||||
vec![],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"http",
|
||||
vec!["video"],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.start_all().expect("start_all should succeed");
|
||||
|
||||
let sender = manager.sender();
|
||||
// 模拟 http 插件向 video 发送播放命令(http routes 的实际行为)
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "http".to_string(),
|
||||
to: Destination::Plugin("video".to_string()),
|
||||
message: Message::PlayerCommand(PlayerCommand::Play),
|
||||
})
|
||||
.expect("play command should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "http".to_string(),
|
||||
to: Destination::Plugin("video".to_string()),
|
||||
message: Message::PlayerCommand(PlayerCommand::Pause),
|
||||
})
|
||||
.expect("pause command should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "http".to_string(),
|
||||
to: Destination::Plugin("video".to_string()),
|
||||
message: Message::PlayerCommand(PlayerCommand::Next),
|
||||
})
|
||||
.expect("next command should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
.expect("shutdown should send");
|
||||
|
||||
manager.run().expect("run should succeed");
|
||||
|
||||
assert!(
|
||||
has_event(&events, "msg:video:player_cmd:Play"),
|
||||
"video should receive Play command from http"
|
||||
);
|
||||
assert!(
|
||||
has_event(&events, "msg:video:player_cmd:Pause"),
|
||||
"video should receive Pause command from http"
|
||||
);
|
||||
assert!(
|
||||
has_event(&events, "msg:video:player_cmd:Next"),
|
||||
"video should receive Next command from http"
|
||||
);
|
||||
// http 不应该自己收到这些命令(它们直接发往 video)
|
||||
assert!(
|
||||
!has_event(&events, "msg:http:player_cmd:Play"),
|
||||
"http plugin should not receive its own Play command"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||
}
|
||||
|
||||
// ── 测试:配置管理 — ConfigReloadRequest 发送及 ConfigReloaded 广播 ─────────────
|
||||
|
||||
/// POST /api/config 成功场景:发送 ConfigReloadRequest 后 Manager 广播 ConfigReloaded。
|
||||
#[test]
|
||||
fn test_config_update_triggers_reload_broadcast() {
|
||||
let (mut manager, events, dir) = setup("config_update_reload", 1);
|
||||
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"video",
|
||||
vec![],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"http",
|
||||
vec!["video"],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.start_all().expect("start_all should succeed");
|
||||
|
||||
// 先写入新配置文件(模拟 POST /api/config 把文件写到磁盘)
|
||||
let config_path = dir.join("config.json");
|
||||
fs::write(
|
||||
&config_path,
|
||||
config_json_with_playlist("updated-title", 2),
|
||||
)
|
||||
.expect("config file should be updated");
|
||||
|
||||
let sender = manager.sender();
|
||||
// 模拟 http 路由发送 ConfigReloadRequest
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "http".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::ConfigReloadRequest,
|
||||
})
|
||||
.expect("config reload request should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
.expect("shutdown should send");
|
||||
|
||||
manager.run().expect("run should succeed");
|
||||
|
||||
// 所有插件都应收到 ConfigReloaded 广播
|
||||
assert!(
|
||||
has_event(&events, "config_reloaded:updated-title"),
|
||||
"plugins should receive ConfigReloaded broadcast with new title"
|
||||
);
|
||||
assert!(
|
||||
has_event(&events, "msg:video:config_reloaded:updated-title"),
|
||||
"video plugin should receive ConfigReloaded"
|
||||
);
|
||||
assert!(
|
||||
has_event(&events, "msg:http:config_reloaded:updated-title"),
|
||||
"http plugin should receive ConfigReloaded"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||
}
|
||||
|
||||
/// POST /api/config 失败场景:损坏的 JSON 配置不应触发 ConfigReloaded。
|
||||
#[test]
|
||||
fn test_config_update_with_corrupted_json_does_not_reload() {
|
||||
let (mut manager, events, dir) = setup("config_corrupted_json", 1);
|
||||
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"video",
|
||||
vec![],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"http",
|
||||
vec!["video"],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.start_all().expect("start_all should succeed");
|
||||
|
||||
// 写入损坏的 JSON(模拟 http 路由验证失败后不写文件,Manager 不会收到 reload 请求)
|
||||
// 在 routes.rs 中 handle_config_update 先用 config::parse_str 验证,验证失败则直接返回 400
|
||||
// 不会发送 ConfigReloadRequest。因此这里我们验证:不发 ConfigReloadRequest 则不广播 ConfigReloaded。
|
||||
|
||||
let sender = manager.sender();
|
||||
// 不发 ConfigReloadRequest,只发 Shutdown
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
.expect("shutdown should send");
|
||||
|
||||
manager.run().expect("run should succeed");
|
||||
|
||||
// 没有 ConfigReloaded 广播
|
||||
assert!(
|
||||
!has_event(&events, "config_reloaded"),
|
||||
"no ConfigReloaded should be broadcast when config reload was not requested"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||
}
|
||||
|
||||
// ── 测试:播放列表 — GET /api/playlist 包含 playlist + current_index ─────────
|
||||
|
||||
/// GET /api/playlist 成功场景:返回包含 playlist 和 current_index 的快照。
|
||||
/// 通过 PlayerStatus 广播更新 current_index,验证 HttpState 正确记录状态。
|
||||
#[test]
|
||||
fn test_playlist_snapshot_includes_current_index() {
|
||||
let (mut manager, events, dir) = setup("playlist_snapshot", 4);
|
||||
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"video",
|
||||
vec![],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"http",
|
||||
vec!["video"],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.start_all().expect("start_all should succeed");
|
||||
|
||||
let sender = manager.sender();
|
||||
// 模拟播放进度到第 2 个视频
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "video".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::PlayerStatus(PlayerStatusData {
|
||||
running: true,
|
||||
paused: false,
|
||||
in_transition: false,
|
||||
current_index: 2,
|
||||
playlist_length: 4,
|
||||
current_video: Some("video2.mp4".to_string()),
|
||||
}),
|
||||
})
|
||||
.expect("player status should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
.expect("shutdown should send");
|
||||
|
||||
manager.run().expect("run should succeed");
|
||||
|
||||
// http 插件收到了包含正确 current_index 的 PlayerStatus
|
||||
assert!(
|
||||
has_event(&events, "msg:http:player_status:true:false:2:4"),
|
||||
"http plugin should track current_index=2 from PlayerStatus"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||
}
|
||||
|
||||
/// 播放列表为空时,系统不应崩溃(边界条件)。
|
||||
#[test]
|
||||
fn test_empty_playlist_does_not_panic() {
|
||||
let (mut manager, _events, dir) = setup("empty_playlist", 0);
|
||||
|
||||
// 空 playlist 仍能正常启动和关闭
|
||||
let sender = manager.sender();
|
||||
manager.start_all().expect("start_all with empty playlist should succeed");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
.expect("shutdown should send");
|
||||
|
||||
manager.run().expect("run with empty playlist should succeed");
|
||||
|
||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||
}
|
||||
|
||||
// ── 测试:插件管理 API 闭环 ────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/plugins 成功场景:plugin_states Custom 消息更新后状态可读。
|
||||
/// 验证 ServiceManager 的 broadcast_plugin_states() 能通过 Custom 消息传递给 http 插件。
|
||||
#[test]
|
||||
fn test_plugin_states_broadcast_reaches_http_plugin() {
|
||||
let (mut manager, events, dir) = setup("plugin_states_broadcast", 1);
|
||||
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"video",
|
||||
vec![],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"http",
|
||||
vec!["video"],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.start_all().expect("start_all should succeed");
|
||||
|
||||
// ServiceManager 在 start_all() 后会广播 plugin_states
|
||||
// 验证 http 插件收到了 Custom{kind:"plugin_states",...} 消息
|
||||
let sender = manager.sender();
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
.expect("shutdown should send");
|
||||
|
||||
manager.run().expect("run should succeed");
|
||||
|
||||
// http 插件应收到 plugin_states 广播(在 start_all 后由 ServiceManager 自动发送)
|
||||
assert!(
|
||||
has_event(&events, "msg:http:custom:plugin_states:"),
|
||||
"http plugin should receive plugin_states broadcast after start_all"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||
}
|
||||
|
||||
/// POST /api/plugins/:id/enable 闭环:Custom plugin_enable 消息发到 Manager,
|
||||
/// Manager 调用 set_plugin_enabled(),然后 broadcast_plugin_states()。
|
||||
#[test]
|
||||
fn test_plugin_enable_command_processed_by_manager() {
|
||||
let (mut manager, events, dir) = setup("plugin_enable_cmd", 1);
|
||||
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"video",
|
||||
vec![],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"http",
|
||||
vec!["video"],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.start_all().expect("start_all should succeed");
|
||||
|
||||
// 先禁用 video 插件
|
||||
manager
|
||||
.set_plugin_enabled("video", false)
|
||||
.expect("disable video should succeed");
|
||||
|
||||
let sender = manager.sender();
|
||||
// 模拟 http routes 的 plugin_enable_route 发送的消息
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "http".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Custom {
|
||||
kind: "plugin_enable".to_string(),
|
||||
payload: "video".to_string(),
|
||||
},
|
||||
})
|
||||
.expect("plugin_enable command should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
.expect("shutdown should send");
|
||||
|
||||
manager.run().expect("run should succeed");
|
||||
|
||||
// plugin_enable 处理后,Manager 会 broadcast_plugin_states
|
||||
// http 插件会收到新的 plugin_states 广播
|
||||
assert!(
|
||||
has_event(&events, "custom:plugin_states:"),
|
||||
"plugin_states should be broadcast after plugin_enable command"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||
}
|
||||
|
||||
/// POST /api/plugins/:id/disable 闭环:Custom plugin_disable 消息处理后广播状态更新。
|
||||
#[test]
|
||||
fn test_plugin_disable_command_processed_by_manager() {
|
||||
let (mut manager, events, dir) = setup("plugin_disable_cmd", 1);
|
||||
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"video",
|
||||
vec![],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"http",
|
||||
vec!["video"],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.start_all().expect("start_all should succeed");
|
||||
|
||||
let sender = manager.sender();
|
||||
// 模拟 http routes 的 plugin_disable_route 发送的消息
|
||||
// 注意:video 被 http 依赖,但 set_plugin_enabled 不做循环依赖检查,仅操作状态
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "http".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Custom {
|
||||
kind: "plugin_disable".to_string(),
|
||||
payload: "video".to_string(),
|
||||
},
|
||||
})
|
||||
.expect("plugin_disable command should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
.expect("shutdown should send");
|
||||
|
||||
manager.run().expect("run should succeed");
|
||||
|
||||
// plugin_disable 处理后,Manager 会 broadcast_plugin_states,http 收到更新
|
||||
assert!(
|
||||
has_event(&events, "custom:plugin_states:"),
|
||||
"plugin_states should be broadcast after plugin_disable command"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||
}
|
||||
|
||||
// ── 测试:错误场景 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// goto 越界场景:routes.rs 中检查 index >= playlist_length,返回 400。
|
||||
/// 这里通过 ServiceManager 的 plugin_states() 验证 playlist_length 是正确的,
|
||||
/// 确保路由层的越界判断有可靠的数据基础。
|
||||
#[test]
|
||||
fn test_goto_boundary_check_data_correctness() {
|
||||
let (mut manager, events, dir) = setup("goto_boundary", 3);
|
||||
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"video",
|
||||
vec![],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"http",
|
||||
vec!["video"],
|
||||
events.clone(),
|
||||
)));
|
||||
manager.start_all().expect("start_all should succeed");
|
||||
|
||||
let sender = manager.sender();
|
||||
// 发送 PlayerStatus 确保 playlist_length 为 3
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "video".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::PlayerStatus(PlayerStatusData {
|
||||
running: false,
|
||||
paused: true,
|
||||
in_transition: false,
|
||||
current_index: 0,
|
||||
playlist_length: 3,
|
||||
current_video: Some("video0.mp4".to_string()),
|
||||
}),
|
||||
})
|
||||
.expect("player status should send");
|
||||
sender
|
||||
.send(Envelope {
|
||||
from: "test".to_string(),
|
||||
to: Destination::Manager,
|
||||
message: Message::Shutdown,
|
||||
})
|
||||
.expect("shutdown should send");
|
||||
|
||||
manager.run().expect("run should succeed");
|
||||
|
||||
// 验证 http 收到的 PlayerStatus 中 playlist_length=3
|
||||
// 在实际 HTTP 请求中,goto index=3 时 (3 >= 3) 会触发 400
|
||||
assert!(
|
||||
has_event(&events, "player_status:false:true:0:3"),
|
||||
"http plugin should have playlist_length=3 for boundary checking"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||
}
|
||||
|
||||
/// 非法路径场景:validate_managed_path 中的路径穿越防护。
|
||||
/// 通过测试文件路径验证函数直接验证路径拒绝逻辑。
|
||||
#[test]
|
||||
fn test_illegal_path_rejected_by_sanitizer() {
|
||||
// sanitize_filename 的行为:把 "/" "\" ".." 替换为 "_"
|
||||
// 这验证了 routes.rs 第 1691-1693 行的 sanitize_filename 函数
|
||||
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.replace(['/', '\\'], "_").replace("..", "_")
|
||||
}
|
||||
|
||||
// 路径穿越应被清理
|
||||
let dangerous = "../../../etc/passwd";
|
||||
let sanitized = sanitize_filename(dangerous);
|
||||
assert!(
|
||||
!sanitized.contains(".."),
|
||||
"sanitized filename should not contain '..'"
|
||||
);
|
||||
assert!(
|
||||
!sanitized.contains('/'),
|
||||
"sanitized filename should not contain '/'"
|
||||
);
|
||||
|
||||
// 合法文件名保持不变
|
||||
let safe = "video_test.mp4";
|
||||
assert_eq!(sanitize_filename(safe), safe, "safe filename should be unchanged");
|
||||
|
||||
// 包含反斜杠的路径也应被清理
|
||||
let win_path = "..\\..\\windows\\system32";
|
||||
let sanitized_win = sanitize_filename(win_path);
|
||||
assert!(
|
||||
!sanitized_win.contains('\\'),
|
||||
"sanitized filename should not contain backslash"
|
||||
);
|
||||
}
|
||||
|
||||
/// 多插件启动后 plugin_states 的数量和结构正确性验证。
|
||||
#[test]
|
||||
fn test_plugin_states_count_after_startup() {
|
||||
let (mut manager, _events, dir) = setup("plugin_states_count", 1);
|
||||
|
||||
// 注册 3 个插件
|
||||
let events_dummy = Arc::new(Mutex::new(Vec::new()));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"device",
|
||||
vec![],
|
||||
events_dummy.clone(),
|
||||
)));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"video",
|
||||
vec![],
|
||||
events_dummy.clone(),
|
||||
)));
|
||||
manager.register(Box::new(RecordingPlugin::new(
|
||||
"http",
|
||||
vec!["video"],
|
||||
events_dummy.clone(),
|
||||
)));
|
||||
manager.start_all().expect("start_all should succeed");
|
||||
|
||||
// plugin_states() 返回所有插件的状态
|
||||
let states = manager.plugin_states();
|
||||
assert_eq!(
|
||||
states.len(),
|
||||
3,
|
||||
"should have 3 plugin states after registering 3 plugins"
|
||||
);
|
||||
|
||||
// 所有插件默认启用
|
||||
for state in &states {
|
||||
assert!(state.enabled, "plugin '{}' should be enabled by default", state.id);
|
||||
}
|
||||
|
||||
// 禁用一个插件后,状态更新
|
||||
manager
|
||||
.set_plugin_enabled("device", false)
|
||||
.expect("disable device should succeed");
|
||||
|
||||
let states_after = manager.plugin_states();
|
||||
let device_state = states_after.iter().find(|s| s.id == "device");
|
||||
assert!(device_state.is_some(), "device plugin state should exist");
|
||||
assert!(
|
||||
!device_state.unwrap().enabled,
|
||||
"device plugin should be disabled after set_plugin_enabled(false)"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dir).expect("test dir should be removed");
|
||||
}
|
||||
Reference in New Issue
Block a user