use actix_web::{test, web, App}; use chrono::Utc; use search_hub::config::{EngineConfig, Shortcut}; use search_hub::models::Bookmark; use search_hub::storage; use search_hub::web::{handlers, DbPool, SearchApiResponse, ServerConfig}; use serde_json::Value; use std::collections::HashMap; use std::sync::{Arc, RwLock}; use tera::Tera; macro_rules! setup { ($path:expr) => {{ let db_pool = web::Data::new(DbPool::new($path)); let engines = web::Data::new(Arc::new(RwLock::new(Vec::<EngineConfig>::new()))); let cfg = web::Data::new(ServerConfig { port: 8080, bind_address: "127.0.0.1".into(), page_size: 20, workers: 1, tag_weight: 3.0, }); let shortcuts = web::Data::new(HashMap::<String, Shortcut>::new()); let mut tera_ = Tera::default(); tera_ .add_raw_template("index.html", include_str!("../templates/index.html")) .expect("template parse"); let tera = web::Data::new(tera_); test::init_service( App::new() .app_data(tera) .app_data(db_pool) .app_data(engines) .app_data(cfg) .app_data(shortcuts) .service(handlers::index) .service(handlers::search) .service(handlers::api_search) .service(handlers::search_stream), ) .await }}; } fn insert_bookmark(conn: &rusqlite::Connection, title: &str, url: &str, content: &str) { storage::insert_bookmark( conn, &Bookmark { id: 0, title: title.into(), url: url.into(), description: None, source: "bookmark".into(), content: Some(content.into()), tags: None, created_at: Utc::now(), }, ) .expect("insert bookmark"); } fn count_rows(conn: &rusqlite::Connection) -> usize { storage::list_bookmarks(conn, 1, 1000).unwrap_or_default().len() } #[actix_web::test] async fn api_search_returns_matching_bookmarks() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let pool = DbPool::new(&path); let conn = pool.conn(); insert_bookmark(&conn, "Rust programming", "https://rust-lang.org", "Rust is great"); insert_bookmark(&conn, "Python tutorial", "https://python.org", "Python is fun"); assert_eq!(count_rows(&conn), 2); let req = test::TestRequest::get() .uri("/api/search?q=rust") .to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let body: SearchApiResponse = serde_json::from_slice(&test::read_body(resp).await).unwrap(); assert_eq!(body.query, "rust"); assert_eq!(body.page, 1); assert_eq!(body.total_results, 1); assert_eq!(body.results.len(), 1); assert_eq!(body.results[0].title(), "Rust programming"); } #[actix_web::test] async fn api_search_no_match_returns_empty() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let pool = DbPool::new(&path); let conn = pool.conn(); insert_bookmark(&conn, "Rust programming", "https://rust-lang.org", "Rust is great"); let req = test::TestRequest::get() .uri("/api/search?q=nonexistent") .to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let body: SearchApiResponse = serde_json::from_slice(&test::read_body(resp).await).unwrap(); assert_eq!(body.total_results, 0); assert!(body.results.is_empty()); } #[actix_web::test] async fn api_search_empty_query_returns_all() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let pool = DbPool::new(&path); let conn = pool.conn(); insert_bookmark(&conn, "Alpha", "https://a.com", "first"); insert_bookmark(&conn, "Beta", "https://b.com", "second"); assert_eq!(count_rows(&conn), 2); let req = test::TestRequest::get().uri("/api/search").to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let body: SearchApiResponse = serde_json::from_slice(&test::read_body(resp).await).unwrap(); assert_eq!(body.total_results, 2); assert_eq!(body.results.len(), 2); } #[actix_web::test] async fn html_search_renders_matching_bookmarks() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let pool = DbPool::new(&path); let conn = pool.conn(); insert_bookmark( &conn, "Rust programming", "https://rust-lang.org", "Rust is a systems language", ); let req = test::TestRequest::get() .uri("/search?q=rust") .to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let html = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); assert!(html.contains("Rust programming")); assert!(html.contains("rust-lang.org")); } #[actix_web::test] async fn html_search_no_match_shows_no_results() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let pool = DbPool::new(&path); let conn = pool.conn(); insert_bookmark(&conn, "Rust", "https://rust-lang.org", "Rust is great"); let req = test::TestRequest::get() .uri("/search?q=nope") .to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let html = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); assert!(html.contains("no bookmarks found")); } #[actix_web::test] async fn html_search_empty_query_shows_prompt() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let req = test::TestRequest::get().uri("/search").to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let html = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); assert!(html.contains("Bookmark Search")); assert!(html.contains("enter a query")); } #[actix_web::test] async fn index_page_renders() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let req = test::TestRequest::get().uri("/").to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let html = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); assert!(html.contains("Bookmark Search")); } // ---- SSE streaming endpoint tests ---- struct SseEvent { event_type: String, data: String, } fn parse_sse(body: &str) -> Vec<SseEvent> { body.split("\n\n") .filter(|chunk| !chunk.trim().is_empty()) .filter_map(|chunk| { let mut event_type = None; let mut data = None; for line in chunk.lines() { if let Some(val) = line.strip_prefix("event: ") { event_type = Some(val.to_string()); } else if let Some(val) = line.strip_prefix("data: ") { data = Some(val.to_string()); } } match (event_type, data) { (Some(event_type), Some(data)) => Some(SseEvent { event_type, data }), _ => None, } }) .collect() } #[actix_web::test] async fn sse_returns_event_stream_content_type() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let req = test::TestRequest::get() .uri("/api/search/stream?q=rust") .to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); assert_eq!( resp.headers().get("content-type").unwrap().to_str().unwrap(), "text/event-stream" ); } #[actix_web::test] async fn sse_empty_query_returns_done() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let req = test::TestRequest::get() .uri("/api/search/stream") .to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); let events = parse_sse(&body); assert_eq!(events.len(), 1); assert_eq!(events[0].event_type, "done"); } #[actix_web::test] async fn sse_returns_bookmarks_event() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let pool = DbPool::new(&path); let conn = pool.conn(); insert_bookmark(&conn, "Rust programming", "https://rust-lang.org", "Rust is great"); insert_bookmark(&conn, "Python tutorial", "https://python.org", "Python is fun"); let req = test::TestRequest::get() .uri("/api/search/stream?q=rust") .to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); let events = parse_sse(&body); // Should have bookmarks + done assert!(events.len() >= 2); assert_eq!(events[0].event_type, "bookmarks"); assert_eq!(events.last().unwrap().event_type, "done"); let bookmark_data: Value = serde_json::from_str(&events[0].data).unwrap(); assert_eq!(bookmark_data["total"], 1); assert_eq!(bookmark_data["results"].as_array().unwrap().len(), 1); assert_eq!(bookmark_data["results"][0]["type"], "bookmark"); assert_eq!(bookmark_data["results"][0]["title"], "Rust programming"); } #[actix_web::test] async fn sse_no_match_returns_empty_bookmarks() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let pool = DbPool::new(&path); let conn = pool.conn(); insert_bookmark(&conn, "Rust programming", "https://rust-lang.org", "Rust is great"); let req = test::TestRequest::get() .uri("/api/search/stream?q=nonexistent") .to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); let events = parse_sse(&body); assert!(events.len() >= 2); assert_eq!(events[0].event_type, "bookmarks"); let bookmark_data: Value = serde_json::from_str(&events[0].data).unwrap(); assert_eq!(bookmark_data["total"], 0); assert!(bookmark_data["results"].as_array().unwrap().is_empty()); } #[actix_web::test] async fn sse_done_after_bookmarks_with_no_engines() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let pool = DbPool::new(&path); let conn = pool.conn(); insert_bookmark(&conn, "Rust", "https://rust-lang.org", "systems language"); let req = test::TestRequest::get() .uri("/api/search/stream?q=rust") .to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); let events = parse_sse(&body); // Since no external engines configured, should have exactly: bookmarks + done assert_eq!(events.len(), 2); assert_eq!(events[0].event_type, "bookmarks"); assert_eq!(events[1].event_type, "done"); } #[actix_web::test] async fn sse_bang_returns_bang_event() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let mut shortcuts = HashMap::new(); shortcuts.insert( "w".to_string(), Shortcut { trigger: "w".into(), name: "Wikipedia".into(), bang_url: "https://en.wikipedia.org/w/index.php?search={}".into(), engine_index: None, }, ); let shortcuts = web::Data::new(shortcuts); let db_pool = web::Data::new(DbPool::new(&path)); let engines = web::Data::new(Arc::new(RwLock::new(Vec::<EngineConfig>::new()))); let cfg = web::Data::new(ServerConfig { port: 8080, bind_address: "127.0.0.1".into(), page_size: 20, workers: 1, tag_weight: 3.0, }); let mut tera_ = Tera::default(); tera_ .add_raw_template("index.html", include_str!("../templates/index.html")) .expect("template parse"); let tera = web::Data::new(tera_); let app = test::init_service( App::new() .app_data(tera) .app_data(db_pool) .app_data(engines) .app_data(cfg) .app_data(shortcuts) .service(handlers::search_stream), ) .await; let req = test::TestRequest::get() .uri("/api/search/stream?q=%21w+Rust") .to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); let events = parse_sse(&body); assert_eq!(events.len(), 1); assert_eq!(events[0].event_type, "bang"); let bang_data: Value = serde_json::from_str(&events[0].data).unwrap(); assert_eq!(bang_data["trigger"], "!w Rust"); assert_eq!(bang_data["query"], "Rust"); assert!(bang_data["redirect_url"].as_str().unwrap().contains("en.wikipedia.org")); } #[actix_web::test] async fn api_search_pagination() { let tmp = tempfile::NamedTempFile::new().unwrap(); let path = tmp.path().to_str().unwrap().to_string(); let app = setup!(&path); let pool = DbPool::new(&path); let conn = pool.conn(); for i in 0..5 { insert_bookmark( &conn, &format!("Item {i}"), &format!("https://example.com/{i}"), "content", ); } assert_eq!(count_rows(&conn), 5); let req = test::TestRequest::get() .uri("/api/search?q=item&page=1&page_size=2") .to_request(); let resp = test::call_service(&app, req).await; assert!(resp.status().is_success()); let body: SearchApiResponse = serde_json::from_slice(&test::read_body(resp).await).unwrap(); assert_eq!(body.total_results, 5); assert_eq!(body.page_size, 2); assert_eq!(body.results.len(), 2); }