| @@ -0,0 +1,234 @@ |
| +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 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, |
| + }); |
| + 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), |
| + ) |
| + .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")); |
| +} |
| + |
| +#[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); |
| +} |