//! Integration tests for file watching (task 7.8). //! //! Tests exercise `IFileWatchService` through `FileWatchService`, verifying //! that filesystem changes produce the expected broadcast events. use std::sync::{Arc, Mutex}; use std::time::Duration; use nomifun_api_types::WebSocketMessage; use nomifun_file::{FileWatchService, IFileWatchService}; use nomifun_realtime::EventBroadcaster; // ----------------------------------------------------------------------- // Test helpers // ----------------------------------------------------------------------- /// A broadcaster that records every event for later assertion. struct RecordingBroadcaster { events: Mutex>>, } impl RecordingBroadcaster { fn new() -> Self { Self { events: Mutex::new(Vec::new()), } } /// Drain all recorded events. fn take_events(&self) -> Vec> { let mut guard = self.events.lock().unwrap(); std::mem::take(&mut *guard) } } impl EventBroadcaster for RecordingBroadcaster { fn broadcast(&self, event: WebSocketMessage) { self.events.lock().unwrap().push(event); } } fn make_service() -> (Arc, Arc) { let recorder = Arc::new(RecordingBroadcaster::new()); let svc = FileWatchService::new(recorder.clone()).unwrap(); (Arc::new(svc), recorder) } /// Wait a bit for the OS file-system event to propagate and the watcher /// callback to fire. File-system notifications are inherently asynchronous. async fn settle() { tokio::time::sleep(Duration::from_millis(500)).await; } // ----------------------------------------------------------------------- // Single-file watching // ----------------------------------------------------------------------- #[tokio::test] async fn start_watch_and_detect_change() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("watched.txt"); std::fs::write(&file, "initial").unwrap(); let (svc, recorder) = make_service(); svc.start_watch(file.to_str().unwrap()).await.unwrap(); // Modify the file. settle().await; std::fs::write(&file, "updated").unwrap(); settle().await; let events = recorder.take_events(); assert!( events.iter().any(|e| e.name == "fileWatch.fileChanged"), "expected fileWatch.fileChanged event, got: {events:?}" ); let ev = events.iter().find(|e| e.name == "fileWatch.fileChanged").unwrap(); assert!(ev.data["file_path"].as_str().is_some()); assert!(ev.data["event_type"].as_str().is_some()); } #[tokio::test] async fn stop_watch_stops_events() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("stop_me.txt"); std::fs::write(&file, "v1").unwrap(); let (svc, recorder) = make_service(); let path_str = file.to_str().unwrap(); svc.start_watch(path_str).await.unwrap(); settle().await; svc.stop_watch(path_str).await.unwrap(); // Drain any events from the watch setup. recorder.take_events(); // Modify after stop — should NOT produce events. std::fs::write(&file, "v2").unwrap(); settle().await; let events = recorder.take_events(); assert!(events.is_empty(), "expected no events after stop, got: {events:?}"); } #[tokio::test] async fn stop_all_watches_clears_file_watches() { let dir = tempfile::tempdir().unwrap(); let file_a = dir.path().join("a.txt"); let file_b = dir.path().join("b.txt"); std::fs::write(&file_a, "a").unwrap(); std::fs::write(&file_b, "b").unwrap(); let (svc, recorder) = make_service(); svc.start_watch(file_a.to_str().unwrap()).await.unwrap(); svc.start_watch(file_b.to_str().unwrap()).await.unwrap(); settle().await; svc.stop_all_watches().await.unwrap(); recorder.take_events(); std::fs::write(&file_a, "a2").unwrap(); std::fs::write(&file_b, "b2").unwrap(); settle().await; let events = recorder.take_events(); assert!(events.is_empty(), "expected no events after stop_all, got: {events:?}"); } #[tokio::test] async fn idempotent_start_watch() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("idem.txt"); std::fs::write(&file, "x").unwrap(); let (svc, _recorder) = make_service(); let path_str = file.to_str().unwrap(); svc.start_watch(path_str).await.unwrap(); // Second start should be a no-op, not an error. svc.start_watch(path_str).await.unwrap(); } #[tokio::test] async fn watch_nonexistent_file_returns_error() { let (svc, _recorder) = make_service(); let result = svc.start_watch("/tmp/nonexistent_12345.txt").await; assert!(result.is_err()); } // ----------------------------------------------------------------------- // Workspace Office file watching // ----------------------------------------------------------------------- #[tokio::test] async fn office_watch_detects_docx() { let dir = tempfile::tempdir().unwrap(); let (svc, recorder) = make_service(); svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap(); settle().await; // Create a .docx file. std::fs::write(dir.path().join("report.docx"), "fake docx").unwrap(); settle().await; let events = recorder.take_events(); assert!( events.iter().any(|e| e.name == "workspaceOfficeWatch.fileAdded"), "expected workspaceOfficeWatch.fileAdded event, got: {events:?}" ); } #[tokio::test] async fn office_watch_detects_xlsx() { let dir = tempfile::tempdir().unwrap(); let (svc, recorder) = make_service(); svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap(); settle().await; std::fs::write(dir.path().join("data.xlsx"), "fake xlsx").unwrap(); settle().await; let events = recorder.take_events(); assert!( events.iter().any(|e| e.name == "workspaceOfficeWatch.fileAdded"), "expected fileAdded for .xlsx, got: {events:?}" ); } #[tokio::test] async fn office_watch_detects_pptx() { let dir = tempfile::tempdir().unwrap(); let (svc, recorder) = make_service(); svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap(); settle().await; std::fs::write(dir.path().join("slides.pptx"), "fake pptx").unwrap(); settle().await; let events = recorder.take_events(); assert!( events.iter().any(|e| e.name == "workspaceOfficeWatch.fileAdded"), "expected fileAdded for .pptx, got: {events:?}" ); } #[tokio::test] async fn office_watch_ignores_non_office_files() { let dir = tempfile::tempdir().unwrap(); let (svc, recorder) = make_service(); svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap(); settle().await; // Drain any setup events. recorder.take_events(); // Create a non-Office file — should NOT trigger. std::fs::write(dir.path().join("notes.txt"), "hello").unwrap(); settle().await; let events = recorder.take_events(); let office_events: Vec<_> = events .iter() .filter(|e| e.name == "workspaceOfficeWatch.fileAdded") .collect(); assert!( office_events.is_empty(), "expected no office events for .txt, got: {office_events:?}" ); } #[tokio::test] async fn stop_office_watch_stops_events() { let dir = tempfile::tempdir().unwrap(); let (svc, recorder) = make_service(); let ws = dir.path().to_str().unwrap(); svc.start_office_watch(ws).await.unwrap(); settle().await; svc.stop_office_watch(ws).await.unwrap(); recorder.take_events(); std::fs::write(dir.path().join("after_stop.docx"), "data").unwrap(); settle().await; let events = recorder.take_events(); let office_events: Vec<_> = events .iter() .filter(|e| e.name == "workspaceOfficeWatch.fileAdded") .collect(); assert!( office_events.is_empty(), "expected no events after stop, got: {office_events:?}" ); } #[tokio::test] async fn idempotent_office_watch() { let dir = tempfile::tempdir().unwrap(); let (svc, _recorder) = make_service(); let ws = dir.path().to_str().unwrap(); svc.start_office_watch(ws).await.unwrap(); // Second call should be a no-op. svc.start_office_watch(ws).await.unwrap(); } #[tokio::test] async fn office_watch_event_has_correct_fields() { let dir = tempfile::tempdir().unwrap(); let (svc, recorder) = make_service(); svc.start_office_watch(dir.path().to_str().unwrap()).await.unwrap(); settle().await; std::fs::write(dir.path().join("check.docx"), "content").unwrap(); settle().await; let events = recorder.take_events(); let ev = events.iter().find(|e| e.name == "workspaceOfficeWatch.fileAdded"); assert!(ev.is_some(), "expected fileAdded event, got: {events:?}"); let data = &ev.unwrap().data; assert!( data["file_path"].as_str().is_some_and(|p| p.ends_with("check.docx")), "file_path should end with check.docx: {data:?}" ); assert!( data["workspace"].as_str().is_some(), "workspace should be present: {data:?}" ); }