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,274 @@
use nomifun_api_types::ConversionTarget;
use nomifun_office::ConversionService;
use rust_xlsxwriter::{Format, Workbook};
use std::path::PathBuf;
use tempfile::TempDir;
fn create_simple_xlsx(dir: &TempDir) -> PathBuf {
let path = dir.path().join("test.xlsx");
let mut wb = Workbook::new();
let sheet = wb.add_worksheet();
sheet.set_name("Sheet1").unwrap();
sheet.write_string(0, 0, "Name").unwrap();
sheet.write_string(0, 1, "Age").unwrap();
sheet.write_string(1, 0, "Alice").unwrap();
sheet.write_number(1, 1, 30.0).unwrap();
sheet.write_string(2, 0, "Bob").unwrap();
sheet.write_number(2, 1, 25.0).unwrap();
wb.save(&path).unwrap();
path
}
fn create_multi_sheet_xlsx(dir: &TempDir) -> PathBuf {
let path = dir.path().join("multi.xlsx");
let mut wb = Workbook::new();
let s1 = wb.add_worksheet();
s1.set_name("Users").unwrap();
s1.write_string(0, 0, "Name").unwrap();
s1.write_number(0, 1, 1.0).unwrap();
let s2 = wb.add_worksheet();
s2.set_name("Products").unwrap();
s2.write_string(0, 0, "Item").unwrap();
s2.write_number(0, 1, 9.99).unwrap();
let s3 = wb.add_worksheet();
s3.set_name("Empty").unwrap();
wb.save(&path).unwrap();
path
}
fn create_xlsx_with_merges(dir: &TempDir) -> PathBuf {
let path = dir.path().join("merged.xlsx");
let mut wb = Workbook::new();
let sheet = wb.add_worksheet();
sheet.set_name("Merged").unwrap();
let fmt = Format::new();
sheet.merge_range(0, 0, 1, 2, "Merged Title", &fmt).unwrap();
sheet.write_string(2, 0, "A").unwrap();
sheet.write_string(2, 1, "B").unwrap();
sheet.write_string(2, 2, "C").unwrap();
wb.save(&path).unwrap();
path
}
#[allow(clippy::approx_constant)] // 3.14 is test data, not an approximation of PI
fn create_xlsx_with_types(dir: &TempDir) -> PathBuf {
let path = dir.path().join("types.xlsx");
let mut wb = Workbook::new();
let sheet = wb.add_worksheet();
sheet.set_name("Types").unwrap();
sheet.write_string(0, 0, "text").unwrap();
sheet.write_number(0, 1, 42.0).unwrap();
sheet.write_number(0, 2, 3.14).unwrap();
sheet.write_boolean(0, 3, true).unwrap();
wb.save(&path).unwrap();
path
}
// DC-1: Excel → JSON (normal)
#[tokio::test]
async fn dc1_excel_to_json_simple() {
let dir = TempDir::new().unwrap();
let path = create_simple_xlsx(&dir);
let svc = ConversionService::new(None);
let resp = svc
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
.await
.unwrap();
assert_eq!(resp.to, "excel-json");
assert!(resp.result.success);
assert!(resp.result.error.is_none());
let data = resp.result.data.unwrap();
let sheets = data["sheets"].as_array().unwrap();
assert_eq!(sheets.len(), 1);
assert_eq!(sheets[0]["name"], "Sheet1");
let rows = sheets[0]["data"].as_array().unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0][0], "Name");
assert_eq!(rows[0][1], "Age");
assert_eq!(rows[1][0], "Alice");
assert_eq!(rows[1][1], 30.0);
assert_eq!(rows[2][0], "Bob");
assert_eq!(rows[2][1], 25.0);
}
// DC-2: Excel → JSON (multiple sheets)
#[tokio::test]
async fn dc2_excel_to_json_multi_sheet() {
let dir = TempDir::new().unwrap();
let path = create_multi_sheet_xlsx(&dir);
let svc = ConversionService::new(None);
let resp = svc
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
.await
.unwrap();
assert!(resp.result.success);
let data = resp.result.data.unwrap();
let sheets = data["sheets"].as_array().unwrap();
assert_eq!(sheets.len(), 3);
assert_eq!(sheets[0]["name"], "Users");
assert_eq!(sheets[1]["name"], "Products");
assert_eq!(sheets[2]["name"], "Empty");
}
// DC-3: Excel → JSON (with merged cells)
#[tokio::test]
async fn dc3_excel_to_json_with_merges() {
let dir = TempDir::new().unwrap();
let path = create_xlsx_with_merges(&dir);
let svc = ConversionService::new(None);
let resp = svc
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
.await
.unwrap();
assert!(resp.result.success);
let data = resp.result.data.unwrap();
let sheet = &data["sheets"][0];
assert_eq!(sheet["name"], "Merged");
let merges = sheet["merges"].as_array().unwrap();
assert!(!merges.is_empty());
let merge = &merges[0];
assert_eq!(merge["s"]["r"], 0);
assert_eq!(merge["s"]["c"], 0);
assert_eq!(merge["e"]["r"], 1);
assert_eq!(merge["e"]["c"], 2);
}
// DC-4: Excel → JSON (file not found)
#[tokio::test]
async fn dc4_excel_to_json_file_not_found() {
let svc = ConversionService::new(None);
let resp = svc
.convert("/nonexistent/file.xlsx", ConversionTarget::ExcelJson)
.await
.unwrap();
assert_eq!(resp.to, "excel-json");
assert!(!resp.result.success);
assert!(resp.result.data.is_none());
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
}
// DC-6: Word → Markdown (pandoc not available)
#[tokio::test]
async fn dc6_word_to_markdown_file_not_found() {
let svc = ConversionService::new(None);
let resp = svc
.convert("/nonexistent/file.docx", ConversionTarget::Markdown)
.await
.unwrap();
assert_eq!(resp.to, "markdown");
assert!(!resp.result.success);
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
}
// DC-8: PPT → JSON (officecli not available)
#[tokio::test]
async fn dc8_ppt_to_json_file_not_found() {
let svc = ConversionService::new(None);
let resp = svc
.convert("/nonexistent/file.pptx", ConversionTarget::PptJson)
.await
.unwrap();
assert_eq!(resp.to, "ppt-json");
assert!(!resp.result.success);
assert!(resp.result.error.as_ref().unwrap().contains("file not found"));
}
// DC-8b: PPT → JSON (officecli not installed — configured path invalid and not in PATH)
#[tokio::test]
async fn dc8b_ppt_to_json_officecli_not_installed() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("slides.pptx");
std::fs::write(&path, b"fake pptx content").unwrap();
// Use a wrapper that ensures officecli is not found
let svc = ConversionService::new(Some("/nonexistent/officecli".into()));
let resp = svc
.convert(path.to_str().unwrap(), ConversionTarget::PptJson)
.await
.unwrap();
// Conversion should fail — either because officecli is not found or because
// it fails to parse the fake file. Either way, success must be false.
assert!(!resp.result.success);
assert!(resp.result.error.is_some());
}
// Excel cell type handling
#[tokio::test]
#[allow(clippy::approx_constant)] // 3.14 is test data, not an approximation of PI
async fn excel_to_json_cell_types() {
let dir = TempDir::new().unwrap();
let path = create_xlsx_with_types(&dir);
let svc = ConversionService::new(None);
let resp = svc
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
.await
.unwrap();
assert!(resp.result.success);
let data = resp.result.data.unwrap();
let row = &data["sheets"][0]["data"][0];
assert_eq!(row[0], "text");
assert_eq!(row[1], 42.0);
assert!((row[2].as_f64().unwrap() - 3.14).abs() < 0.01);
assert_eq!(row[3], true);
}
// Excel empty file
#[tokio::test]
async fn excel_to_json_empty_workbook() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("empty.xlsx");
let mut wb = Workbook::new();
wb.add_worksheet().set_name("Empty").unwrap();
wb.save(&path).unwrap();
let svc = ConversionService::new(None);
let resp = svc
.convert(path.to_str().unwrap(), ConversionTarget::ExcelJson)
.await
.unwrap();
assert!(resp.result.success);
let data = resp.result.data.unwrap();
let sheets = data["sheets"].as_array().unwrap();
assert_eq!(sheets.len(), 1);
assert_eq!(sheets[0]["name"], "Empty");
}
// Response always returns success=true at convert() level (error wrapped in result)
#[tokio::test]
async fn convert_always_returns_ok_with_result_wrapper() {
let svc = ConversionService::new(None);
let targets = [
ConversionTarget::Markdown,
ConversionTarget::ExcelJson,
ConversionTarget::PptJson,
];
for target in targets {
let resp = svc.convert("/nonexistent/path", target).await;
assert!(resp.is_ok());
assert!(!resp.unwrap().result.success);
}
}
@@ -0,0 +1,456 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use nomifun_api_types::WebSocketMessage;
use nomifun_office::OfficeError;
use nomifun_office::proxy::{ProxyError, ProxyService};
use nomifun_office::types::DocType;
use nomifun_office::watch_manager::{OfficecliWatchManager, ProcessHandle, ProcessSpawner};
use nomifun_realtime::EventBroadcaster;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
// ---------------------------------------------------------------------------
// Test infrastructure
// ---------------------------------------------------------------------------
struct MockProcessHandle {
alive: AtomicBool,
}
impl MockProcessHandle {
fn new() -> Self {
Self {
alive: AtomicBool::new(true),
}
}
}
impl ProcessHandle for MockProcessHandle {
fn kill(&self) {
self.alive.store(false, Ordering::SeqCst);
}
fn is_alive(&self) -> bool {
self.alive.load(Ordering::SeqCst)
}
}
struct HttpMockSpawner {
response_template: String,
}
#[async_trait::async_trait]
impl ProcessSpawner for HttpMockSpawner {
async fn spawn_officecli(
&self,
_file_path: &str,
port: u16,
_doc_type: DocType,
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
let resp = self.response_template.replace("__PORT__", &port.to_string());
tokio::spawn(async move {
let listener = TcpListener::bind(format!("127.0.0.1:{port}")).await.unwrap();
for _ in 0..10 {
if let Ok((mut stream, _)) = listener.accept().await {
let resp = resp.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 4096];
let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
let _ = stream.write_all(resp.as_bytes()).await;
let _ = stream.shutdown().await;
});
}
}
});
Ok(Box::new(MockProcessHandle::new()))
}
async fn install_officecli(&self) -> Result<(), OfficeError> {
Ok(())
}
async fn is_officecli_installed(&self) -> bool {
true
}
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
Ok(())
}
}
struct TcpOnlySpawner;
#[async_trait::async_trait]
impl ProcessSpawner for TcpOnlySpawner {
async fn spawn_officecli(
&self,
_file_path: &str,
port: u16,
_doc_type: DocType,
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.map_err(|e| OfficeError::StartFailed(e.to_string()))?;
std::mem::forget(listener);
Ok(Box::new(MockProcessHandle::new()))
}
async fn install_officecli(&self) -> Result<(), OfficeError> {
Ok(())
}
async fn is_officecli_installed(&self) -> bool {
true
}
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
Ok(())
}
}
struct NoopBroadcaster;
impl EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: WebSocketMessage<serde_json::Value>) {}
}
fn build_http_response(status: u16, headers: &[(&str, &str)], body: &str) -> String {
let status_text = match status {
200 => "OK",
302 => "Found",
404 => "Not Found",
_ => "Unknown",
};
let mut resp = format!("HTTP/1.1 {status} {status_text}\r\n");
for (k, v) in headers {
resp.push_str(&format!("{k}: {v}\r\n"));
}
if !headers.iter().any(|(k, _)| k.to_lowercase() == "content-length") {
resp.push_str(&format!("Content-Length: {}\r\n", body.len()));
}
resp.push_str("\r\n");
resp.push_str(body);
resp
}
async fn setup_proxy(doc_type: DocType, response_template: &str) -> (ProxyService, u16, tempfile::TempDir) {
let spawner = HttpMockSpawner {
response_template: response_template.to_owned(),
};
let mgr = Arc::new(OfficecliWatchManager::new(Arc::new(spawner), Arc::new(NoopBroadcaster)));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let port = mgr.start(file.to_str().unwrap(), doc_type).await.unwrap();
let proxy = ProxyService::new(mgr);
(proxy, port, dir)
}
async fn setup_ssrf_proxy(doc_type: DocType) -> (ProxyService, u16, tempfile::TempDir) {
let mgr = Arc::new(OfficecliWatchManager::new(
Arc::new(TcpOnlySpawner),
Arc::new(NoopBroadcaster),
));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.docx");
std::fs::write(&file, b"test").unwrap();
let port = mgr.start(file.to_str().unwrap(), doc_type).await.unwrap();
let proxy = ProxyService::new(mgr);
(proxy, port, dir)
}
// ---------------------------------------------------------------------------
// RP-2: PPT proxy SSRF protection — inactive port rejected
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp2_ppt_proxy_ssrf_rejects_inactive_port() {
let (proxy, _active_port, _dir) = setup_ssrf_proxy(DocType::Ppt).await;
let result = proxy.forward(9999, "/index.html", DocType::Ppt, &[]).await;
let err = result.unwrap_err();
assert!(matches!(err, ProxyError::PortNotActive(9999)));
}
// ---------------------------------------------------------------------------
// RP-4: Office watch proxy SSRF protection — inactive port rejected
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp4_office_watch_proxy_ssrf_rejects_inactive_port() {
let (proxy, _active_port, _dir) = setup_ssrf_proxy(DocType::Word).await;
let result = proxy.forward_watch(9999, "/", &[]).await;
assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(9999)));
}
// ---------------------------------------------------------------------------
// SSRF: wrong doc_type rejected even when port is active
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ssrf_wrong_doc_type_rejected() {
let (proxy, active_port, _dir) = setup_ssrf_proxy(DocType::Word).await;
let result = proxy.forward(active_port, "/index.html", DocType::Ppt, &[]).await;
assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(_)));
}
// ---------------------------------------------------------------------------
// H-1-13.8 fix: forward_watch accepts Excel session ports
// ---------------------------------------------------------------------------
#[tokio::test]
async fn forward_watch_accepts_excel_session_port() {
let response = build_http_response(200, &[("Content-Type", "text/plain")], "Excel preview");
let (proxy, port, _dir) = setup_proxy(DocType::Excel, &response).await;
let result = proxy.forward_watch(port, "/", &[]).await.unwrap();
assert_eq!(result.status, 200);
let body = String::from_utf8(result.body).unwrap();
assert!(body.contains("Excel preview"));
}
// ---------------------------------------------------------------------------
// forward_watch accepts Word session ports
// ---------------------------------------------------------------------------
#[tokio::test]
async fn forward_watch_accepts_word_session_port() {
let response = build_http_response(200, &[("Content-Type", "text/plain")], "Word preview");
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
let result = proxy.forward_watch(port, "/", &[]).await.unwrap();
assert_eq!(result.status, 200);
let body = String::from_utf8(result.body).unwrap();
assert!(body.contains("Word preview"));
}
// ---------------------------------------------------------------------------
// forward_watch rejects PPT session ports
// ---------------------------------------------------------------------------
#[tokio::test]
async fn forward_watch_rejects_ppt_session_port() {
let (proxy, ppt_port, _dir) = setup_ssrf_proxy(DocType::Ppt).await;
let result = proxy.forward_watch(ppt_port, "/", &[]).await;
assert!(matches!(result.unwrap_err(), ProxyError::PortNotActive(_)));
}
// ---------------------------------------------------------------------------
// RP-1 / RP-3: Proxy forwards plain text response
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp1_rp3_proxy_forwards_plain_text() {
let response = build_http_response(200, &[("Content-Type", "text/plain")], "Hello from preview");
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
let result = proxy.forward(port, "/index.html", DocType::Ppt, &[]).await.unwrap();
assert_eq!(result.status, 200);
let body = String::from_utf8(result.body).unwrap();
assert!(body.contains("Hello from preview"));
}
// ---------------------------------------------------------------------------
// RP-5: HTML injection — navigation guard script injected
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp5_proxy_injects_navigation_guard_in_html() {
let html_body = "<html><head><title>Preview</title></head><body>Content</body></html>";
let response = build_http_response(200, &[("Content-Type", "text/html")], html_body);
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
let result = proxy.forward(port, "/", DocType::Word, &[]).await.unwrap();
assert_eq!(result.status, 200);
let body = String::from_utf8(result.body).unwrap();
assert!(body.contains("<script>"), "should inject navigation guard script");
assert!(
body.contains(&format!("'/api/office-watch-proxy/{port}'")),
"guard should reference correct proxy base path"
);
assert!(
body.contains("<title>Preview</title>"),
"should preserve original HTML content"
);
}
// ---------------------------------------------------------------------------
// RP-5b: Non-HTML response should NOT inject script
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp5b_proxy_does_not_inject_in_json() {
let response = build_http_response(200, &[("Content-Type", "application/json")], r#"{"ok":true}"#);
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
let result = proxy.forward(port, "/api/data", DocType::Ppt, &[]).await.unwrap();
let body = String::from_utf8(result.body).unwrap();
assert!(!body.contains("<script>"), "should not inject script in JSON responses");
}
// ---------------------------------------------------------------------------
// RP-7: Hop-by-hop header stripping
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp7_proxy_strips_hop_by_hop_headers() {
let response = build_http_response(
200,
&[
("Content-Type", "text/plain"),
("Connection", "keep-alive"),
("Keep-Alive", "timeout=5"),
("X-Custom", "preserved"),
],
"body",
);
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
let result = proxy.forward(port, "/", DocType::Word, &[]).await.unwrap();
let header_names: Vec<&str> = result.headers.iter().map(|(k, _)| k.as_str()).collect();
assert!(
!header_names.contains(&"connection"),
"connection header should be stripped"
);
assert!(
!header_names.contains(&"keep-alive"),
"keep-alive header should be stripped"
);
assert!(header_names.contains(&"x-custom"), "custom header should be preserved");
}
// ---------------------------------------------------------------------------
// X-Frame-Options set to SAMEORIGIN
// ---------------------------------------------------------------------------
#[tokio::test]
async fn proxy_sets_x_frame_options_sameorigin() {
let response = build_http_response(200, &[("Content-Type", "text/plain")], "body");
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
let result = proxy.forward(port, "/", DocType::Ppt, &[]).await.unwrap();
let xfo = result
.headers
.iter()
.find(|(k, _)| k == "x-frame-options")
.map(|(_, v)| v.as_str());
assert_eq!(xfo, Some("SAMEORIGIN"));
}
// ---------------------------------------------------------------------------
// HTML content-length stripped after injection
// ---------------------------------------------------------------------------
#[tokio::test]
async fn proxy_removes_content_length_for_html() {
let html_body = "<html><head></head><body></body></html>";
let response = build_http_response(200, &[("Content-Type", "text/html")], html_body);
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
let result = proxy.forward(port, "/", DocType::Word, &[]).await.unwrap();
let has_cl = result.headers.iter().any(|(k, _)| k == "content-length");
assert!(
!has_cl,
"content-length should be stripped for HTML responses (body size changed after injection)"
);
}
// ---------------------------------------------------------------------------
// Non-HTML content-length preserved
// ---------------------------------------------------------------------------
#[tokio::test]
async fn proxy_preserves_content_length_for_non_html() {
let response = build_http_response(200, &[("Content-Type", "application/json")], r#"{"ok":true}"#);
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response).await;
let result = proxy.forward(port, "/api/data", DocType::Ppt, &[]).await.unwrap();
let has_cl = result.headers.iter().any(|(k, _)| k == "content-length");
assert!(has_cl, "content-length should be preserved for non-HTML");
}
// ---------------------------------------------------------------------------
// RP-6: Location rewriting
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp6_proxy_rewrites_location_header() {
let response_template = build_http_response(
302,
&[
("Content-Type", "text/html"),
("Location", "http://localhost:__PORT__/new/path"),
],
"",
);
let (proxy, port, _dir) = setup_proxy(DocType::Ppt, &response_template).await;
let result = proxy.forward(port, "/old", DocType::Ppt, &[]).await.unwrap();
assert_eq!(result.status, 302);
let location = result
.headers
.iter()
.find(|(k, _)| k == "location")
.map(|(_, v)| v.as_str());
assert_eq!(location, Some(format!("/api/ppt-proxy/{port}/new/path").as_str()));
}
// ---------------------------------------------------------------------------
// RP-6b: Location rewriting for root-relative paths
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp6b_proxy_rewrites_root_relative_location() {
let response_template = build_http_response(302, &[("Content-Type", "text/html"), ("Location", "/redirected")], "");
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response_template).await;
let result = proxy.forward(port, "/old", DocType::Word, &[]).await.unwrap();
let location = result
.headers
.iter()
.find(|(k, _)| k == "location")
.map(|(_, v)| v.as_str());
assert_eq!(
location,
Some(format!("/api/office-watch-proxy/{port}/redirected").as_str())
);
}
// ---------------------------------------------------------------------------
// Proxy forwards 404 status correctly
// ---------------------------------------------------------------------------
#[tokio::test]
async fn proxy_forwards_404_status() {
let response = build_http_response(404, &[("Content-Type", "text/plain")], "Not Found");
let (proxy, port, _dir) = setup_proxy(DocType::Word, &response).await;
let result = proxy.forward(port, "/missing", DocType::Word, &[]).await.unwrap();
assert_eq!(result.status, 404);
}
@@ -0,0 +1,264 @@
use nomifun_api_types::{PreviewHistoryTargetDto, PreviewSnapshotInfoDto};
use nomifun_common::PreviewContentType;
use nomifun_office::SnapshotService;
fn make_target(content_type: PreviewContentType, file_path: Option<&str>) -> PreviewHistoryTargetDto {
PreviewHistoryTargetDto {
content_type,
file_path: file_path.map(String::from),
workspace: None,
file_name: None,
title: None,
language: None,
conversation_id: None,
}
}
fn make_target_full(
content_type: PreviewContentType,
file_path: Option<&str>,
workspace: Option<&str>,
conversation_id: Option<i64>,
) -> PreviewHistoryTargetDto {
PreviewHistoryTargetDto {
content_type,
file_path: file_path.map(String::from),
workspace: workspace.map(String::from),
file_name: None,
title: None,
language: None,
conversation_id,
}
}
// SH-1: Save snapshot
#[tokio::test]
async fn sh1_save_snapshot() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
let info = svc.save(&target, "# Hello").await.unwrap();
assert!(!info.id.is_empty(), "id must not be empty");
assert!(info.created_at > 0, "createdAt must be current timestamp");
assert_eq!(info.size, 7, "size must be content byte count");
assert_eq!(info.content_type, PreviewContentType::Markdown);
assert!(!info.label.is_empty(), "label must not be empty");
}
// SH-2: List snapshots (ordered by createdAt)
#[tokio::test]
async fn sh2_list_snapshots_ordered() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
let s1 = svc.save(&target, "content-1").await.unwrap();
let s2 = svc.save(&target, "content-2").await.unwrap();
let s3 = svc.save(&target, "content-3").await.unwrap();
let list = svc.list(&target).await.unwrap();
assert_eq!(list.len(), 3);
assert_eq!(list[0].id, s1.id);
assert_eq!(list[1].id, s2.id);
assert_eq!(list[2].id, s3.id);
assert!(list[0].created_at <= list[1].created_at);
assert!(list[1].created_at <= list[2].created_at);
}
// SH-3: Get snapshot content
#[tokio::test]
async fn sh3_get_snapshot_content() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
let info = svc.save(&target, "# Hello").await.unwrap();
let resp = svc.get_content(&target, &info.id).await.unwrap();
assert!(resp.is_some());
let resp = resp.unwrap();
assert_eq!(resp.content, "# Hello");
assert_eq!(resp.snapshot.id, info.id);
assert_eq!(resp.snapshot.size, info.size);
assert_eq!(resp.snapshot.content_type, PreviewContentType::Markdown);
}
// SH-4: Get nonexistent snapshot returns None
#[tokio::test]
async fn sh4_get_nonexistent_snapshot() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
let resp = svc.get_content(&target, "nonexistent").await.unwrap();
assert!(resp.is_none());
}
// SH-4b: Get nonexistent snapshot from a target with existing snapshots
#[tokio::test]
async fn sh4b_get_nonexistent_snapshot_with_existing() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
svc.save(&target, "some content").await.unwrap();
let resp = svc.get_content(&target, "does-not-exist").await.unwrap();
assert!(resp.is_none());
}
// SH-5: Trim snapshots over limit (50)
#[tokio::test]
async fn sh5_trim_over_limit() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Code, Some("/c.rs"));
let mut first_ids: Vec<String> = Vec::new();
for i in 0..51 {
let info = svc.save(&target, &format!("content-{i}")).await.unwrap();
if i == 0 {
first_ids.push(info.id.clone());
}
}
let list = svc.list(&target).await.unwrap();
assert_eq!(list.len(), 50, "must trim to 50 snapshots");
assert!(
!list.iter().any(|s| s.id == first_ids[0]),
"oldest snapshot must be removed"
);
}
// SH-5b: Verify snapshot file is also deleted
#[tokio::test]
async fn sh5b_trim_deletes_files() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Code, Some("/c.rs"));
let first = svc.save(&target, "first").await.unwrap();
for i in 1..51 {
svc.save(&target, &format!("content-{i}")).await.unwrap();
}
let resp = svc.get_content(&target, &first.id).await.unwrap();
assert!(resp.is_none(), "trimmed snapshot file must not be readable");
}
// SH-6: Different targets are isolated
#[tokio::test]
async fn sh6_different_targets_isolated() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let t1 = make_target(PreviewContentType::Markdown, Some("/a.md"));
let t2 = make_target(PreviewContentType::Markdown, Some("/b.md"));
svc.save(&t1, "content-a").await.unwrap();
svc.save(&t2, "content-b1").await.unwrap();
svc.save(&t2, "content-b2").await.unwrap();
let list1 = svc.list(&t1).await.unwrap();
let list2 = svc.list(&t2).await.unwrap();
assert_eq!(list1.len(), 1);
assert_eq!(list2.len(), 2);
}
// SH-6b: Different content types are isolated
#[tokio::test]
async fn sh6b_different_content_types_isolated() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let t1 = make_target(PreviewContentType::Markdown, Some("/a.md"));
let t2 = make_target(PreviewContentType::Html, Some("/a.md"));
svc.save(&t1, "md content").await.unwrap();
svc.save(&t2, "html content").await.unwrap();
let list1 = svc.list(&t1).await.unwrap();
let list2 = svc.list(&t2).await.unwrap();
assert_eq!(list1.len(), 1);
assert_eq!(list2.len(), 1);
}
// SH-7: Target field combination produces different SHA-1 directories
#[tokio::test]
async fn sh7_target_field_combination_different_hash() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let t1 = make_target(PreviewContentType::Markdown, Some("/a.md"));
let t2 = make_target_full(PreviewContentType::Markdown, Some("/a.md"), Some("/ws"), Some(1));
svc.save(&t1, "content-1").await.unwrap();
svc.save(&t2, "content-2").await.unwrap();
let list1 = svc.list(&t1).await.unwrap();
let list2 = svc.list(&t2).await.unwrap();
assert_eq!(list1.len(), 1);
assert_eq!(list2.len(), 1);
let r1 = svc.get_content(&t1, &list1[0].id).await.unwrap().unwrap();
let r2 = svc.get_content(&t2, &list2[0].id).await.unwrap().unwrap();
assert_eq!(r1.content, "content-1");
assert_eq!(r2.content, "content-2");
}
// SH-7b: Verify SHA-1 directory naming by inspecting filesystem
#[tokio::test]
async fn sh7b_sha1_directory_naming() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Markdown, Some("/a.md"));
svc.save(&target, "test").await.unwrap();
let history_dir = tmp.path().join("preview-history");
let mut entries = std::fs::read_dir(&history_dir).unwrap();
let dir_entry = entries.next().unwrap().unwrap();
let dir_name = dir_entry.file_name().to_string_lossy().to_string();
assert_eq!(dir_name.len(), 40, "SHA-1 hex must be 40 characters");
assert!(
dir_name.chars().all(|c| c.is_ascii_hexdigit()),
"directory name must be hex"
);
let index = std::fs::read_to_string(dir_entry.path().join("index.json")).unwrap();
let snapshots: Vec<PreviewSnapshotInfoDto> = serde_json::from_str(&index).unwrap();
assert_eq!(snapshots.len(), 1);
}
// Extra: List on empty directory returns empty vec
#[tokio::test]
async fn list_empty_returns_empty_vec() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = make_target(PreviewContentType::Pdf, Some("/doc.pdf"));
let list = svc.list(&target).await.unwrap();
assert!(list.is_empty());
}
// Extra: Save preserves file_name and file_path from target
#[tokio::test]
async fn save_preserves_target_metadata() {
let tmp = tempfile::tempdir().unwrap();
let svc = SnapshotService::new(tmp.path());
let target = PreviewHistoryTargetDto {
content_type: PreviewContentType::Word,
file_path: Some("/docs/report.docx".into()),
workspace: None,
file_name: Some("report.docx".into()),
title: None,
language: None,
conversation_id: None,
};
let info = svc.save(&target, "word content").await.unwrap();
assert_eq!(info.file_path.as_deref(), Some("/docs/report.docx"));
assert_eq!(info.file_name.as_deref(), Some("report.docx"));
}
@@ -0,0 +1,321 @@
use std::net::TcpListener;
use std::sync::Arc;
use nomifun_office::StarOfficeDetector;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn allocate_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
async fn mock_star_office_server(
port: u16,
health_ok: bool,
status_body: &'static str,
index_body: &'static str,
) -> tokio::task::JoinHandle<()> {
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.await
.unwrap();
tokio::spawn(async move {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(s) => s,
Err(_) => break,
};
let status_body = status_body.to_string();
let index_body = index_body.to_string();
tokio::spawn(async move {
let mut buf = vec![0u8; 4096];
let n = match stream.read(&mut buf).await {
Ok(n) => n,
Err(_) => return,
};
let request = String::from_utf8_lossy(&buf[..n]);
let path = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("/");
let (status_line, body) = match path {
"/health" => {
if health_ok {
("HTTP/1.1 200 OK", "ok".to_string())
} else {
("HTTP/1.1 503 Service Unavailable", "down".to_string())
}
}
"/status" => ("HTTP/1.1 200 OK", status_body),
"/" => ("HTTP/1.1 200 OK", index_body),
_ => ("HTTP/1.1 404 Not Found", "not found".to_string()),
};
let response = format!(
"{status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
});
}
})
}
// ---------------------------------------------------------------------------
// SO-1: No available service → returns None
// ---------------------------------------------------------------------------
#[tokio::test]
async fn so1_no_service_returns_none() {
let detector = StarOfficeDetector::new(reqwest::Client::new());
let port = allocate_port();
let url = format!("http://localhost:{port}");
let result = detector.detect_exact(Some(&url), true, Some(50)).await;
assert!(result.is_none());
}
// ---------------------------------------------------------------------------
// SO-2: With preferred URL, no service → returns None
// ---------------------------------------------------------------------------
#[tokio::test]
async fn so2_preferred_url_no_service() {
let detector = StarOfficeDetector::new(reqwest::Client::new());
let result = detector
.detect_exact(Some("http://localhost:59990"), true, Some(50))
.await;
assert!(result.is_none());
}
// ---------------------------------------------------------------------------
// SO-3: Cache behavior — second call hits cache
// ---------------------------------------------------------------------------
#[tokio::test]
async fn so3_cache_hit_returns_cached() {
let port = allocate_port();
let url = format!("http://localhost:{port}");
let detector = Arc::new(StarOfficeDetector::new(reqwest::Client::new()));
let _ = detector.detect_exact(Some(&url), false, Some(50)).await;
let t0 = tokio::time::Instant::now();
let result = detector.detect_exact(Some(&url), false, Some(50)).await;
let elapsed = t0.elapsed();
assert!(result.is_none());
assert!(
elapsed < std::time::Duration::from_millis(100),
"cached call should be fast, took {elapsed:?}"
);
}
// ---------------------------------------------------------------------------
// SO-4: Force ignores cache
// ---------------------------------------------------------------------------
#[tokio::test]
async fn so4_force_bypasses_cache() {
let port = allocate_port();
let url = format!("http://localhost:{port}");
let detector = StarOfficeDetector::new(reqwest::Client::new());
let _ = detector.detect_exact(Some(&url), false, Some(50)).await;
let result = detector.detect_exact(Some(&url), true, Some(50)).await;
assert!(result.is_none());
}
// ---------------------------------------------------------------------------
// SO-5: Detect available service (three-step health check)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn so5_detect_available_service() {
let port = allocate_port();
let handle = mock_star_office_server(
port,
true,
r#"{"status": "idle"}"#,
"<html><head></head><body>Star Office dashboard with decorate room</body></html>",
)
.await;
let detector = StarOfficeDetector::new(reqwest::Client::new());
let url = format!("http://localhost:{port}");
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
assert_eq!(result, Some(format!("http://localhost:{port}")));
handle.abort();
}
// ---------------------------------------------------------------------------
// SO-6: Exclude OpenClaw misidentification
// ---------------------------------------------------------------------------
#[tokio::test]
async fn so6_exclude_openclaw() {
let port = allocate_port();
let handle = mock_star_office_server(
port,
true,
r#"{"status": "idle"}"#,
"<html><head></head><body>Star Office with openclaw control panel</body></html>",
)
.await;
let detector = StarOfficeDetector::new(reqwest::Client::new());
let url = format!("http://localhost:{port}");
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
assert!(result.is_none());
handle.abort();
}
// ---------------------------------------------------------------------------
// Health check step 1 fail: /health returns non-200
// ---------------------------------------------------------------------------
#[tokio::test]
async fn health_step1_fail_returns_none() {
let port = allocate_port();
let handle = mock_star_office_server(
port,
false,
r#"{"status": "idle"}"#,
"<html><body>Star Office</body></html>",
)
.await;
let detector = StarOfficeDetector::new(reqwest::Client::new());
let url = format!("http://localhost:{port}");
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
assert!(result.is_none());
handle.abort();
}
// ---------------------------------------------------------------------------
// Health check step 2 fail: /status has no status markers
// ---------------------------------------------------------------------------
#[tokio::test]
async fn health_step2_no_status_markers() {
let port = allocate_port();
let handle = mock_star_office_server(
port,
true,
"just some random text",
"<html><body>Star Office</body></html>",
)
.await;
let detector = StarOfficeDetector::new(reqwest::Client::new());
let url = format!("http://localhost:{port}");
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
assert!(result.is_none());
handle.abort();
}
// ---------------------------------------------------------------------------
// Health check step 3 fail: / has no feature keywords
// ---------------------------------------------------------------------------
#[tokio::test]
async fn health_step3_no_feature_keywords() {
let port = allocate_port();
let handle = mock_star_office_server(
port,
true,
r#"{"status": "idle"}"#,
"<html><body>Some other application</body></html>",
)
.await;
let detector = StarOfficeDetector::new(reqwest::Client::new());
let url = format!("http://localhost:{port}");
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
assert!(result.is_none());
handle.abort();
}
// ---------------------------------------------------------------------------
// Detect with different status markers
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_with_writing_status() {
let port = allocate_port();
let handle = mock_star_office_server(
port,
true,
r#"{"status": "writing"}"#,
"<html><body>decorate room and asset sidebar</body></html>",
)
.await;
let detector = StarOfficeDetector::new(reqwest::Client::new());
let url = format!("http://localhost:{port}");
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
assert_eq!(result, Some(format!("http://localhost:{port}")));
handle.abort();
}
// ---------------------------------------------------------------------------
// Cache stores hit correctly
// ---------------------------------------------------------------------------
#[tokio::test]
async fn cache_stores_hit_after_success() {
let port = allocate_port();
let handle =
mock_star_office_server(port, true, r#"idle"#, "<html><body>star office dashboard</body></html>").await;
let detector = StarOfficeDetector::new(reqwest::Client::new());
let url = format!("http://localhost:{port}");
let result = detector.detect_exact(Some(&url), true, Some(2000)).await;
assert!(result.is_some());
handle.abort();
let cached = detector.detect_exact(Some(&url), false, Some(50)).await;
assert_eq!(cached, result);
}
// ---------------------------------------------------------------------------
// Cache miss TTL expires quickly
// ---------------------------------------------------------------------------
#[tokio::test]
async fn cache_miss_ttl_expires() {
let detector = StarOfficeDetector::new(reqwest::Client::new());
let port = allocate_port();
let url = format!("http://localhost:{port}");
let _ = detector.detect_exact(Some(&url), false, Some(50)).await;
tokio::time::sleep(std::time::Duration::from_millis(1600)).await;
let port2 = allocate_port();
let handle = mock_star_office_server(port2, true, "idle", "<html><body>star office</body></html>").await;
let url2 = format!("http://localhost:{port2}");
let result = detector.detect_exact(Some(&url2), false, Some(2000)).await;
assert_eq!(result, Some(format!("http://localhost:{port2}")));
handle.abort();
}
@@ -0,0 +1,332 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::Duration;
use nomifun_api_types::WebSocketMessage;
use nomifun_office::{DocType, OfficeError, OfficecliWatchManager, ProcessHandle, ProcessSpawner};
use nomifun_realtime::EventBroadcaster;
// ---------------------------------------------------------------------------
// Test doubles
// ---------------------------------------------------------------------------
struct MockHandle {
alive: AtomicBool,
}
impl MockHandle {
fn new() -> Self {
Self {
alive: AtomicBool::new(true),
}
}
}
impl ProcessHandle for MockHandle {
fn kill(&self) {
self.alive.store(false, Ordering::SeqCst);
}
fn is_alive(&self) -> bool {
self.alive.load(Ordering::SeqCst)
}
}
struct TestSpawner {
installed: AtomicBool,
spawn_count: AtomicU32,
install_count: AtomicU32,
}
impl TestSpawner {
fn new(installed: bool) -> Self {
Self {
installed: AtomicBool::new(installed),
spawn_count: AtomicU32::new(0),
install_count: AtomicU32::new(0),
}
}
}
#[async_trait::async_trait]
impl ProcessSpawner for TestSpawner {
async fn spawn_officecli(
&self,
_file_path: &str,
port: u16,
_doc_type: DocType,
) -> Result<Box<dyn ProcessHandle>, OfficeError> {
self.spawn_count.fetch_add(1, Ordering::SeqCst);
if !self.installed.load(Ordering::SeqCst) {
return Err(OfficeError::OfficecliNotFound);
}
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.map_err(|e| OfficeError::StartFailed(e.to_string()))?;
std::mem::forget(listener);
Ok(Box::new(MockHandle::new()))
}
async fn install_officecli(&self) -> Result<(), OfficeError> {
self.install_count.fetch_add(1, Ordering::SeqCst);
self.installed.store(true, Ordering::SeqCst);
Ok(())
}
async fn is_officecli_installed(&self) -> bool {
self.installed.load(Ordering::SeqCst)
}
async fn check_update(&self, _doc_type: DocType) -> Result<(), OfficeError> {
Ok(())
}
}
struct TestBroadcaster {
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl TestBroadcaster {
fn new() -> Self {
Self {
events: std::sync::Mutex::new(Vec::new()),
}
}
fn event_names(&self) -> Vec<String> {
self.events.lock().unwrap().iter().map(|e| e.name.clone()).collect()
}
fn event_states(&self) -> Vec<String> {
self.events
.lock()
.unwrap()
.iter()
.filter_map(|e| e.data["state"].as_str().map(String::from))
.collect()
}
}
impl EventBroadcaster for TestBroadcaster {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(event);
}
}
fn create_temp_file(dir: &tempfile::TempDir, name: &str) -> String {
let path = dir.path().join(name);
std::fs::write(&path, b"test content").unwrap();
path.to_string_lossy().into_owned()
}
// ---------------------------------------------------------------------------
// WP-2: Session reuse (same file, same doc type)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn wp2_session_reuse_returns_same_port() {
let spawner = Arc::new(TestSpawner::new(true));
let broadcaster = Arc::new(TestBroadcaster::new());
let mgr = OfficecliWatchManager::new(spawner.clone(), broadcaster);
let dir = tempfile::tempdir().unwrap();
let path = create_temp_file(&dir, "doc.docx");
let p1 = mgr.start(&path, DocType::Word).await.unwrap();
let p2 = mgr.start(&path, DocType::Word).await.unwrap();
assert_eq!(p1, p2);
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 1);
}
// ---------------------------------------------------------------------------
// WP-3: Stop removes session and kills process
// ---------------------------------------------------------------------------
#[tokio::test]
async fn wp3_stop_terminates_session() {
let spawner = Arc::new(TestSpawner::new(true));
let broadcaster = Arc::new(TestBroadcaster::new());
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
let dir = tempfile::tempdir().unwrap();
let path = create_temp_file(&dir, "doc.docx");
let port = mgr.start(&path, DocType::Word).await.unwrap();
assert!(mgr.is_active_port(port, DocType::Word));
mgr.stop(&path, DocType::Word).await;
assert!(!mgr.is_active_port(port, DocType::Word));
assert_eq!(mgr.active_session_count(), 0);
}
// ---------------------------------------------------------------------------
// WP-4: Auto-install when officecli not found
// ---------------------------------------------------------------------------
#[tokio::test]
async fn wp4_auto_install_on_not_found() {
let spawner = Arc::new(TestSpawner::new(false));
let broadcaster = Arc::new(TestBroadcaster::new());
let mgr = OfficecliWatchManager::new(spawner.clone(), broadcaster.clone());
let dir = tempfile::tempdir().unwrap();
let path = create_temp_file(&dir, "doc.docx");
let port = mgr.start(&path, DocType::Word).await.unwrap();
assert!(port > 0);
assert_eq!(spawner.install_count.load(Ordering::SeqCst), 1);
let states = broadcaster.event_states();
assert!(states.contains(&"starting".to_string()));
assert!(states.contains(&"installing".to_string()));
assert!(states.contains(&"ready".to_string()));
}
// ---------------------------------------------------------------------------
// EP-1: Excel uses independent session pool
// ---------------------------------------------------------------------------
#[tokio::test]
async fn ep1_excel_independent_session_pool() {
let spawner = Arc::new(TestSpawner::new(true));
let broadcaster = Arc::new(TestBroadcaster::new());
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
let dir = tempfile::tempdir().unwrap();
let path = create_temp_file(&dir, "data.xlsx");
let word_port = mgr.start(&path, DocType::Word).await.unwrap();
let excel_port = mgr.start(&path, DocType::Excel).await.unwrap();
assert_ne!(word_port, excel_port);
assert_eq!(mgr.active_session_count(), 2);
assert!(mgr.is_active_port(word_port, DocType::Word));
assert!(mgr.is_active_port(excel_port, DocType::Excel));
assert!(!mgr.is_active_port(word_port, DocType::Excel));
}
// ---------------------------------------------------------------------------
// PP-1: PPT uses independent session pool
// ---------------------------------------------------------------------------
#[tokio::test]
async fn pp1_ppt_independent_session_pool() {
let spawner = Arc::new(TestSpawner::new(true));
let broadcaster = Arc::new(TestBroadcaster::new());
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
let dir = tempfile::tempdir().unwrap();
let path = create_temp_file(&dir, "slides.pptx");
let port = mgr.start(&path, DocType::Ppt).await.unwrap();
assert!(port > 0);
assert!(mgr.is_active_port(port, DocType::Ppt));
assert!(!mgr.is_active_port(port, DocType::Word));
}
// ---------------------------------------------------------------------------
// PP-3: PPT triggers background version check
// ---------------------------------------------------------------------------
#[tokio::test]
async fn pp3_ppt_background_version_check() {
let spawner = Arc::new(TestSpawner::new(true));
let broadcaster = Arc::new(TestBroadcaster::new());
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
let dir = tempfile::tempdir().unwrap();
let path = create_temp_file(&dir, "slides.pptx");
mgr.start(&path, DocType::Ppt).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
// Version check is fire-and-forget; we just verify it doesn't panic
}
// ---------------------------------------------------------------------------
// Status event naming per doc type
// ---------------------------------------------------------------------------
#[tokio::test]
async fn status_events_use_correct_prefix() {
let spawner = Arc::new(TestSpawner::new(true));
let broadcaster = Arc::new(TestBroadcaster::new());
let mgr = OfficecliWatchManager::new(spawner, broadcaster.clone());
let dir = tempfile::tempdir().unwrap();
let f1 = create_temp_file(&dir, "a.docx");
let f2 = create_temp_file(&dir, "b.xlsx");
let f3 = create_temp_file(&dir, "c.pptx");
mgr.start(&f1, DocType::Word).await.unwrap();
mgr.start(&f2, DocType::Excel).await.unwrap();
mgr.start(&f3, DocType::Ppt).await.unwrap();
let names = broadcaster.event_names();
assert!(names.contains(&"word-preview.status".to_string()));
assert!(names.contains(&"excel-preview.status".to_string()));
assert!(names.contains(&"ppt-preview.status".to_string()));
}
// ---------------------------------------------------------------------------
// stop_all lifecycle
// ---------------------------------------------------------------------------
#[tokio::test]
async fn stop_all_clears_all_sessions() {
let spawner = Arc::new(TestSpawner::new(true));
let broadcaster = Arc::new(TestBroadcaster::new());
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
let dir = tempfile::tempdir().unwrap();
let f1 = create_temp_file(&dir, "a.docx");
let f2 = create_temp_file(&dir, "b.xlsx");
let f3 = create_temp_file(&dir, "c.pptx");
mgr.start(&f1, DocType::Word).await.unwrap();
mgr.start(&f2, DocType::Excel).await.unwrap();
mgr.start(&f3, DocType::Ppt).await.unwrap();
assert_eq!(mgr.active_session_count(), 3);
mgr.stop_all();
assert_eq!(mgr.active_session_count(), 0);
}
// ---------------------------------------------------------------------------
// SSRF defense: is_active_port returns false for non-active ports
// ---------------------------------------------------------------------------
#[tokio::test]
async fn rp2_inactive_port_rejected() {
let spawner = Arc::new(TestSpawner::new(true));
let broadcaster = Arc::new(TestBroadcaster::new());
let mgr = OfficecliWatchManager::new(spawner, broadcaster);
assert!(!mgr.is_active_port(8080, DocType::Word));
assert!(!mgr.is_active_port(9999, DocType::Ppt));
}
// ---------------------------------------------------------------------------
// Stop then restart creates new session
// ---------------------------------------------------------------------------
#[tokio::test]
async fn stop_then_restart_creates_new_session() {
let spawner = Arc::new(TestSpawner::new(true));
let broadcaster = Arc::new(TestBroadcaster::new());
let mgr = OfficecliWatchManager::new(spawner.clone(), broadcaster);
let dir = tempfile::tempdir().unwrap();
let path = create_temp_file(&dir, "doc.docx");
let p1 = mgr.start(&path, DocType::Word).await.unwrap();
mgr.stop(&path, DocType::Word).await;
let p2 = mgr.start(&path, DocType::Word).await.unwrap();
assert_ne!(p1, p2);
assert_eq!(spawner.spawn_count.load(Ordering::SeqCst), 2);
}