search_hub

at c5226aa Raw

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,
        });
        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,
    });
    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);
}