From efadcd1ef6036d106abc898cb0e47f6307c92527 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 12:48:13 -0400 Subject: [PATCH] test(search): cover SQLite FTS5 parity Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- Cargo.lock | 1 + crates/buzz-search/Cargo.toml | 1 + .../tests/sqlite_fts_integration.rs | 287 ++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 crates/buzz-search/tests/sqlite_fts_integration.rs diff --git a/Cargo.lock b/Cargo.lock index 24df5afd0..da4ce3312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1289,6 +1289,7 @@ name = "buzz-search" version = "0.1.0" dependencies = [ "buzz-core", + "buzz-db", "sqlx", "thiserror 2.0.18", "tokio", diff --git a/crates/buzz-search/Cargo.toml b/crates/buzz-search/Cargo.toml index e42bcc804..7a2c50af0 100644 --- a/crates/buzz-search/Cargo.toml +++ b/crates/buzz-search/Cargo.toml @@ -15,3 +15,4 @@ thiserror = { workspace = true } [dev-dependencies] tokio = { workspace = true } +buzz-db = { workspace = true } diff --git a/crates/buzz-search/tests/sqlite_fts_integration.rs b/crates/buzz-search/tests/sqlite_fts_integration.rs new file mode 100644 index 000000000..085a13cac --- /dev/null +++ b/crates/buzz-search/tests/sqlite_fts_integration.rs @@ -0,0 +1,287 @@ +//! SQLite FTS5 parity coverage for the single-node profile. + +use buzz_core::CommunityId; +use buzz_db::Db; +use buzz_search::{ChannelScope, SearchMode, SearchQuery, SearchService}; +use sqlx::SqlitePool; +use uuid::Uuid; + +async fn setup() -> (Db, CommunityId) { + let db = Db::new_sqlite("sqlite::memory:").await.expect("SQLite DB"); + let community = db + .ensure_configured_community(&format!("{}.example", Uuid::new_v4())) + .await + .expect("community") + .id; + (db, community) +} + +#[allow(clippy::too_many_arguments)] +async fn insert_event( + pool: &SqlitePool, + community: CommunityId, + id: [u8; 32], + pubkey: [u8; 32], + kind: i32, + content: &str, + channel: Option, + created_at: i64, +) { + sqlx::query("INSERT INTO events (community_id,id,pubkey,created_at,kind,tags_json,content,sig,channel_id,received_at,event_json) VALUES (?1,?2,?3,?4,?5,'[]',?6,?7,?8,?4,'{}')") + .bind(community.as_uuid().to_string()) + .bind(id.as_slice()) + .bind(pubkey.as_slice()) + .bind(created_at) + .bind(kind) + .bind(content) + .bind([0_u8; 64].as_slice()) + .bind(channel.map(|id| id.to_string())) + .execute(pool) + .await + .expect("insert event"); +} + +fn query(community: CommunityId, text: &str) -> SearchQuery { + SearchQuery { + community, + q: text.into(), + channel_scope: ChannelScope::Any, + kinds: None, + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + mode: SearchMode::FullText, + } +} + +#[tokio::test] +async fn tenant_channel_kind_author_and_time_filters_match_sqlite_rows() { + let (db, community) = setup().await; + let foreign = db + .ensure_configured_community(&format!("{}.example", Uuid::new_v4())) + .await + .unwrap() + .id; + let pool = db.sqlite_pool_clone().unwrap(); + let channel = Uuid::new_v4(); + let other_channel = Uuid::new_v4(); + let author = [7; 32]; + insert_event( + &pool, + community, + [1; 32], + author, + 9, + "filter needle", + Some(channel), + 100, + ) + .await; + insert_event( + &pool, + community, + [2; 32], + [8; 32], + 9, + "filter needle", + Some(other_channel), + 200, + ) + .await; + insert_event( + &pool, + community, + [3; 32], + author, + 0, + "filter needle", + None, + 300, + ) + .await; + insert_event( + &pool, + foreign, + [4; 32], + author, + 9, + "filter needle", + Some(channel), + 100, + ) + .await; + + let service = SearchService::sqlite(pool); + let mut q = query(community, "needle"); + q.channel_scope = ChannelScope::Channels(vec![channel]); + q.kinds = Some(vec![9]); + q.authors = Some(vec![author.to_vec()]); + q.since = Some(90); + q.until = Some(110); + let result = service.search(&q).await.unwrap(); + assert_eq!( + result + .hits + .iter() + .map(|hit| hit.event_id) + .collect::>(), + vec![[1; 32]] + ); + + q.channel_scope = ChannelScope::ChannelLessOnly; + q.kinds = Some(vec![0]); + q.since = None; + q.until = None; + let result = service.search(&q).await.unwrap(); + assert_eq!( + result + .hits + .iter() + .map(|hit| hit.event_id) + .collect::>(), + vec![[3; 32]] + ); +} + +#[tokio::test] +async fn prefix_full_text_pagination_and_query_bounds_work() { + let (db, community) = setup().await; + let pool = db.sqlite_pool_clone().unwrap(); + for (index, content) in ["project alpha", "project alpine", "project beta"] + .into_iter() + .enumerate() + { + insert_event( + &pool, + community, + [index as u8 + 1; 32], + [9; 32], + 9, + content, + None, + 100 + index as i64, + ) + .await; + } + let service = SearchService::sqlite(pool); + let mut q = query(community, "alp"); + assert!(service.search(&q).await.unwrap().hits.is_empty()); + q.mode = SearchMode::Prefix; + assert_eq!(service.search(&q).await.unwrap().hits.len(), 2); + q.q = "project al...".into(); + assert_eq!(service.search(&q).await.unwrap().hits.len(), 2); + q.q = "project".into(); + q.per_page = 1; + let first = service.search(&q).await.unwrap(); + q.page = 2; + let second = service.search(&q).await.unwrap(); + assert_eq!(first.hits.len(), 1); + assert_eq!(second.hits.len(), 1); + assert_ne!(first.hits[0].event_id, second.hits[0].event_id); + q.q = format!("project\0{}", "x".repeat(20_000)); + let _ = service.search(&q).await.unwrap(); + q.page = u32::MAX; + assert_eq!(service.search(&q).await.unwrap().page, 1_000); +} + +#[tokio::test] +async fn allowlist_and_delete_update_triggers_preserve_storage_privacy() { + let (db, community) = setup().await; + let pool = db.sqlite_pool_clone().unwrap(); + for (index, kind) in [9, 1059, 30300, 30350, 30622, 44100, 44101, 44200] + .into_iter() + .enumerate() + { + insert_event( + &pool, + community, + [index as u8 + 1; 32], + [4; 32], + kind, + "private marker", + None, + 100, + ) + .await; + } + let service = SearchService::sqlite(pool.clone()); + let q = query(community, "marker"); + let result = service.search(&q).await.unwrap(); + assert_eq!( + result.hits.iter().map(|hit| hit.kind).collect::>(), + vec![9] + ); + + sqlx::query( + "UPDATE events SET content = 'replacement token' WHERE community_id = ?1 AND id = ?2", + ) + .bind(community.as_uuid().to_string()) + .bind([1_u8; 32].as_slice()) + .execute(&pool) + .await + .unwrap(); + assert!(service.search(&q).await.unwrap().hits.is_empty()); + assert_eq!( + service + .search(&query(community, "replacement")) + .await + .unwrap() + .hits + .len(), + 1 + ); + sqlx::query("DELETE FROM events WHERE community_id = ?1 AND id = ?2") + .bind(community.as_uuid().to_string()) + .bind([1_u8; 32].as_slice()) + .execute(&pool) + .await + .unwrap(); + assert!(service + .search(&query(community, "replacement")) + .await + .unwrap() + .hits + .is_empty()); +} + +#[tokio::test] +async fn migration_backfills_existing_rows_and_survives_reopen() { + let path = std::env::temp_dir().join(format!("buzz-search-{}.sqlite", Uuid::new_v4())); + let url = path.to_string_lossy().to_string(); + let db = Db::new_sqlite(&url).await.unwrap(); + let community = db + .ensure_configured_community("reopen.example") + .await + .unwrap() + .id; + let pool = db.sqlite_pool_clone().unwrap(); + insert_event( + &pool, + community, + [5; 32], + [6; 32], + 9, + "durable orchard", + None, + 100, + ) + .await; + pool.close().await; + drop(db); + + let reopened = Db::new_sqlite(&url).await.unwrap(); + let service = SearchService::sqlite(reopened.sqlite_pool_clone().unwrap()); + assert_eq!( + service + .search(&query(community, "orchard")) + .await + .unwrap() + .hits + .len(), + 1 + ); + drop(reopened); + let _ = std::fs::remove_file(path); +}