mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(search): add newest-first sort to bridge search (#8371)
## Summary Adds an opt-in newest-first order to bridge search, so clients can offer "Most recent" alongside relevance. - `buzz-search`: `SearchQuery` gains `sort: SearchSort`. `Relevance` keeps the existing `ts_rank_cd` ordering. `Newest` orders hits by `created_at DESC, id`, and `page` steps through that order. Newest skips the exact-profile-lexeme prioritization, which only applies to relevance ranking. - Bridge `POST /query`: search filters accept `search_sort: "newest"`. When the field is missing or has another value, results stay in relevance order. Community scope, channel intersection and per-hit authorization are unchanged. - NIP-11 lists `buzz-search-sort` in `supported_extensions`. Clients only send the field to relays that advertise it, so they never get relevance results labelled as newest. - WebSocket NIP-50 search stays relevance-only. `NOSTR.md` documents the bridge extension. ### Related issue N/A ### Testing - New Postgres integration test `newest_sort_pages_by_time_then_id_within_scope`. It pages a channel in newest order, including an equal-timestamp tie split across a page boundary and a newer hit outside the channel scope. It checks that the pages join up in `(created_at desc, id asc)` order and that relevance still ranks the strongest match first. - Unit tests: - `bridge_search_sort_extension_selects_newest_only_when_named` - `advertises_nip_cl_and_search_sort_extensions` - Ran the buzz-search Postgres integration suite, the relay `nip11` and `bridge_search` unit tests, `cargo fmt --check`, and `cargo clippy -p buzz-search -p buzz-relay --all-targets -D warnings` on Linux. - Local relay exercised through signed `POST /query`: - NIP-11 lists `buzz-search-sort`. - Relevance and newest return different orders. - Newest pages 1–3 return 25 hits with no gaps or duplicates, including same-second ties across page boundaries. - A non-member gets no hits from a private channel; a member gets all of them. - Client integration is in a companion block/buzz-app change. It was exercised end to end against this relay through the production broker: switching sort, paging, scope changes and capability fallback. Deployment note: clients only offer newest sorting after a relay with this change advertises `buzz-search-sort`. --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2
Justfile
2
Justfile
@@ -531,7 +531,7 @@ test-unit:
|
||||
# are infra-free; config parses env into a struct and never connects.
|
||||
# `^config::` is anchored so it does not also select nip_fi_config.
|
||||
cargo nextest run -p buzz-relay --lib --bin buzz-relay \
|
||||
-E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
|
||||
-E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
|
||||
# Note on audio::join::tests scope: the full suite is infra-free (no DB,
|
||||
# no Redis). The infra-free audio/FI regression witnesses — bootstrap
|
||||
# ordering barrier, CommitConfirmed arm, pending-close invisibility,
|
||||
|
||||
2
NOSTR.md
2
NOSTR.md
@@ -67,7 +67,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
|
||||
| **NIP-42 authentication** | ✅ | Proactive challenge; optional pubkey allowlist |
|
||||
| **NIP-11 relay info** | ✅ | `GET /` with `Accept: application/nostr+json` |
|
||||
| **Blossom media** | ✅ | `PUT /media/upload` (BUD-02), `GET /media/{sha256}.{ext}` (BUD-01) |
|
||||
| **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":["<uuid>"]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. |
|
||||
| **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":["<uuid>"]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. HTTP `POST /query` also accepts `search_sort: "newest"` (advertised as `buzz-search-sort` in NIP-11 `supported_extensions`) for newest-first results, paged with `page`. |
|
||||
| **NIP-10 threads** | ✅ | WS-submitted replies with `["e","<root>","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. |
|
||||
| **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored community-globally (`channel_id=None` inside the connected community). Delivered via `#p`-filtered subscriptions. Not indexed in search. |
|
||||
| **DM discovery** | ✅ | DM creation emits kind:39000 (with `hidden` tag) + kind:44100 membership notifications. NIP-29 clients discover DMs via standard group discovery flow. |
|
||||
|
||||
@@ -547,6 +547,15 @@ fn extract_search_mode(raw: &Value) -> buzz_search::SearchMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge-only `search_sort` extension, advertised as `buzz-search-sort`.
|
||||
/// Absent or unrecognized values keep relevance order.
|
||||
fn extract_search_sort(raw: &Value) -> buzz_search::SearchSort {
|
||||
match raw.get("search_sort").and_then(Value::as_str) {
|
||||
Some("newest") => buzz_search::SearchSort::Newest,
|
||||
_ => buzz_search::SearchSort::Relevance,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_search_page(raw: &Value) -> u32 {
|
||||
raw.get("page")
|
||||
.or_else(|| raw.get("search_page"))
|
||||
@@ -2427,6 +2436,7 @@ async fn handle_bridge_search(
|
||||
page: search_page,
|
||||
per_page: limit,
|
||||
mode: search_mode,
|
||||
sort: extract_search_sort(raw),
|
||||
};
|
||||
|
||||
let search_result = state
|
||||
@@ -2450,7 +2460,7 @@ async fn handle_bridge_search(
|
||||
.await
|
||||
.map_err(|e| db_read_error("search fetch error", &e))?;
|
||||
|
||||
// Build lookup map to preserve FTS relevance ordering.
|
||||
// Build lookup map to preserve FTS ordering.
|
||||
let event_map: std::collections::HashMap<[u8; 32], &buzz_core::StoredEvent> = stored_events
|
||||
.iter()
|
||||
.map(|ev| (ev.event.id.to_bytes(), ev))
|
||||
@@ -3251,6 +3261,26 @@ pub(crate) mod postgres_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_search_sort_extension_selects_newest_only_when_named() {
|
||||
for (raw, expected) in [
|
||||
(
|
||||
serde_json::json!({ "search": "pro" }),
|
||||
buzz_search::SearchSort::Relevance,
|
||||
),
|
||||
(
|
||||
serde_json::json!({ "search": "pro", "search_sort": "oldest" }),
|
||||
buzz_search::SearchSort::Relevance,
|
||||
),
|
||||
(
|
||||
serde_json::json!({ "search": "pro", "search_sort": "newest" }),
|
||||
buzz_search::SearchSort::Newest,
|
||||
),
|
||||
] {
|
||||
assert_eq!(extract_search_sort(&raw), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_search_mode_extension_accepts_prefix_snake_or_camel_case() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -844,6 +844,7 @@ async fn handle_search_req(
|
||||
page,
|
||||
per_page: SEARCH_PAGE_SIZE,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
};
|
||||
|
||||
let search_result = match state.search.search(&search_query).await {
|
||||
|
||||
@@ -247,6 +247,8 @@ impl RelayInfo {
|
||||
"nip-ar".to_string(),
|
||||
// NIP-CL: identity tags, labels and system channels.
|
||||
"nip-cl".to_string(),
|
||||
// Bridge `/query` search accepts `search_sort: "newest"`.
|
||||
"buzz-search-sort".to_string(),
|
||||
];
|
||||
let gif = gif_provider.map(|provider| {
|
||||
supported_extensions.push("buzz-gif".to_string());
|
||||
@@ -666,10 +668,11 @@ mod tests {
|
||||
.contains(&"buzz-gif".to_string()));
|
||||
}
|
||||
|
||||
/// NIP-CL: clients refuse labels and system channels unless the relay
|
||||
/// says it supports them, so every document must advertise it.
|
||||
/// Clients refuse NIP-CL labels and system channels, and newest-first
|
||||
/// search, unless the relay says it supports them, so every document must
|
||||
/// advertise both.
|
||||
#[test]
|
||||
fn advertises_nip_cl_extension() {
|
||||
fn advertises_nip_cl_and_search_sort_extensions() {
|
||||
let info = RelayInfo::build(
|
||||
None,
|
||||
None,
|
||||
@@ -679,10 +682,9 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(info
|
||||
.supported_extensions
|
||||
.expect("extensions")
|
||||
.contains(&"nip-cl".to_string()));
|
||||
let extensions = info.supported_extensions.expect("extensions");
|
||||
assert!(extensions.contains(&"nip-cl".to_string()));
|
||||
assert!(extensions.contains(&"buzz-search-sort".to_string()));
|
||||
}
|
||||
|
||||
/// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard
|
||||
|
||||
@@ -28,7 +28,9 @@ pub mod query;
|
||||
|
||||
pub use buzz_core::CommunityId;
|
||||
pub use error::SearchError;
|
||||
pub use query::{search, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult};
|
||||
pub use query::{
|
||||
search, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult, SearchSort,
|
||||
};
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
|
||||
@@ -67,6 +67,15 @@ pub enum SearchMode {
|
||||
Prefix,
|
||||
}
|
||||
|
||||
/// Result ordering for a search.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SearchSort {
|
||||
/// `ts_rank_cd` relevance, newest first among equal ranks.
|
||||
Relevance,
|
||||
/// Newest first, ignoring relevance; ties ordered by event id.
|
||||
Newest,
|
||||
}
|
||||
|
||||
/// A community-scoped FTS query.
|
||||
///
|
||||
/// The community is REQUIRED at the type level — there is no construction path
|
||||
@@ -98,6 +107,8 @@ pub struct SearchQuery {
|
||||
pub per_page: u32,
|
||||
/// Matching semantics for the search text.
|
||||
pub mode: SearchMode,
|
||||
/// Result ordering. Paging is over this order.
|
||||
pub sort: SearchSort,
|
||||
}
|
||||
|
||||
/// A single FTS hit. The relay refetches the canonical `StoredEvent` and
|
||||
@@ -122,7 +133,7 @@ pub struct SearchHit {
|
||||
/// Result of a search.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchResult {
|
||||
/// Hits on this page, ordered by relevance then created_at desc.
|
||||
/// Hits on this page, in the query's `sort` order.
|
||||
pub hits: Vec<SearchHit>,
|
||||
/// 1-indexed page returned.
|
||||
pub page: u32,
|
||||
@@ -209,7 +220,7 @@ fn normalized_search_text(q: &str) -> Option<String> {
|
||||
/// AND deleted_at IS NULL
|
||||
/// AND search_tsv @@ query
|
||||
/// [+ channel scope, kinds, authors, since, until]
|
||||
/// ORDER BY rank DESC, created_at DESC, id
|
||||
/// ORDER BY rank DESC, created_at DESC, id -- SearchSort::Newest: created_at DESC, id
|
||||
/// LIMIT $per_page OFFSET (($page - 1) * $per_page)
|
||||
/// ```
|
||||
///
|
||||
@@ -237,7 +248,8 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
|
||||
// short exact display name off the bounded first page. Keep the same result
|
||||
// set and pagination contract, but put rows containing the whole lexeme
|
||||
// first for this one narrow caller shape.
|
||||
let prioritize_exact_profile_lexeme = query.mode == SearchMode::Prefix
|
||||
let prioritize_exact_profile_lexeme = query.sort == SearchSort::Relevance
|
||||
&& query.mode == SearchMode::Prefix
|
||||
&& query.kinds.as_deref() == Some(&[0][..])
|
||||
&& search_text.chars().count() <= 2;
|
||||
|
||||
@@ -306,7 +318,9 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
|
||||
qb.push(")");
|
||||
}
|
||||
|
||||
if prioritize_exact_profile_lexeme {
|
||||
if query.sort == SearchSort::Newest {
|
||||
qb.push(" ORDER BY created_at DESC, id LIMIT ");
|
||||
} else if prioritize_exact_profile_lexeme {
|
||||
qb.push(" ORDER BY search_tsv @@ websearch_to_tsquery('simple', ");
|
||||
qb.push_bind(&search_text);
|
||||
qb.push(") DESC, rank DESC, created_at DESC, id LIMIT ");
|
||||
|
||||
@@ -123,6 +123,7 @@ async fn search_finds_event_in_same_community() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("search ok");
|
||||
@@ -171,6 +172,7 @@ async fn search_does_not_return_other_community_events() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -188,6 +190,7 @@ async fn search_does_not_return_other_community_events() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -232,6 +235,7 @@ async fn kind0_search_by_display_name_works_without_flattening() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -292,6 +296,7 @@ async fn short_kind0_prefix_prioritizes_exact_lexeme_on_a_noisy_page() {
|
||||
page: 1,
|
||||
per_page: 3,
|
||||
mode: buzz_search::SearchMode::Prefix,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("short profile prefix search ok");
|
||||
@@ -358,6 +363,7 @@ async fn prefix_mode_matches_final_token_prefix_without_changing_full_text() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("full text search ok");
|
||||
@@ -383,6 +389,7 @@ async fn prefix_mode_matches_final_token_prefix_without_changing_full_text() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::Prefix,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("prefix search ok");
|
||||
@@ -402,6 +409,7 @@ async fn prefix_mode_matches_final_token_prefix_without_changing_full_text() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::Prefix,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("multi-token prefix search ok");
|
||||
@@ -420,6 +428,7 @@ async fn prefix_mode_matches_final_token_prefix_without_changing_full_text() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::Prefix,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("completed-token exact + trailing-prefix search ok");
|
||||
@@ -469,6 +478,7 @@ async fn prefix_mode_handles_tsquery_boundary_punctuation() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::Prefix,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("prefix punctuation search ok");
|
||||
@@ -527,6 +537,7 @@ async fn prefix_mode_preserves_storage_level_privacy_exclusions() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::Prefix,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("prefix privacy search ok");
|
||||
@@ -607,6 +618,7 @@ async fn channel_scope_restricts_results() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -626,6 +638,7 @@ async fn channel_scope_restricts_results() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -644,6 +657,7 @@ async fn channel_scope_restricts_results() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -662,6 +676,7 @@ async fn channel_scope_restricts_results() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -701,6 +716,7 @@ async fn deleted_events_are_excluded() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -732,6 +748,7 @@ async fn empty_query_returns_empty_result_no_roundtrip() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -799,6 +816,7 @@ async fn since_until_filters() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -843,6 +861,7 @@ async fn pagination_works() {
|
||||
page: 1,
|
||||
per_page: 3,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -860,6 +879,7 @@ async fn pagination_works() {
|
||||
page: 3,
|
||||
per_page: 3,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -872,6 +892,85 @@ async fn pagination_works() {
|
||||
teardown(pool, &schema).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn newest_sort_pages_by_time_then_id_within_scope() {
|
||||
let (pool, schema) = setup().await;
|
||||
|
||||
let c = mk_community(&pool, "a.example").await;
|
||||
let channel = Uuid::new_v4();
|
||||
let pk = rand_bytes32();
|
||||
let most_relevant = [9u8; 32];
|
||||
let tie_low = [1u8; 32];
|
||||
let tie_high = [2u8; 32];
|
||||
let newest = [3u8; 32];
|
||||
let rows = [
|
||||
(
|
||||
most_relevant,
|
||||
"sortword sortword sortword sortword",
|
||||
1_700_000_000,
|
||||
),
|
||||
(tie_high, "sortword once", 1_700_000_100),
|
||||
(tie_low, "sortword once", 1_700_000_100),
|
||||
(newest, "sortword once", 1_700_000_200),
|
||||
];
|
||||
for (id, content, created_at) in rows {
|
||||
insert_event(&pool, c, id, pk, 9, content, Some(channel), created_at).await;
|
||||
}
|
||||
// Newer than every scoped row, but outside the requested channel scope.
|
||||
insert_event(
|
||||
&pool,
|
||||
c,
|
||||
rand_bytes32(),
|
||||
pk,
|
||||
9,
|
||||
"sortword once",
|
||||
Some(Uuid::new_v4()),
|
||||
1_700_000_300,
|
||||
)
|
||||
.await;
|
||||
|
||||
let svc = SearchService::new(pool.clone());
|
||||
let page = |page, sort| {
|
||||
let svc = svc.clone();
|
||||
async move {
|
||||
svc.search(&SearchQuery {
|
||||
community: c,
|
||||
q: "sortword".into(),
|
||||
channel_scope: ChannelScope::Channels(vec![channel]),
|
||||
kinds: None,
|
||||
authors: None,
|
||||
since: None,
|
||||
until: None,
|
||||
page,
|
||||
per_page: 2,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| hit.event_id)
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
};
|
||||
|
||||
let newest_order = [
|
||||
page(1, buzz_search::SearchSort::Newest).await,
|
||||
page(2, buzz_search::SearchSort::Newest).await,
|
||||
page(3, buzz_search::SearchSort::Newest).await,
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(newest_order, vec![newest, tie_low, tie_high, most_relevant]);
|
||||
assert_eq!(
|
||||
page(1, buzz_search::SearchSort::Relevance).await[0],
|
||||
most_relevant
|
||||
);
|
||||
|
||||
teardown(pool, &schema).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn channel_less_only_excludes_per_channel_events() {
|
||||
@@ -952,6 +1051,7 @@ async fn channel_less_only_excludes_per_channel_events() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1000,6 +1100,7 @@ async fn nul_bytes_in_query_are_sanitized() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("NUL-containing search query should not bubble a Postgres error");
|
||||
@@ -1043,6 +1144,7 @@ async fn enormous_page_number_is_clamped() {
|
||||
page: u32::MAX,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("huge page number should be bounded, not error");
|
||||
@@ -1072,6 +1174,7 @@ async fn very_long_query_is_bounded_before_pg_parse() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("long search query should be capped before Postgres parses it");
|
||||
@@ -1213,6 +1316,7 @@ async fn excluded_kinds_are_storage_level_unsearchable() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("search ok");
|
||||
@@ -1309,6 +1413,7 @@ async fn author_only_kinds_are_storage_level_unsearchable() {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("search ok");
|
||||
@@ -1416,6 +1521,7 @@ async fn p_gated_persistent_kinds_have_storage_null_tsvector() {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
})
|
||||
.await
|
||||
.expect("search ok");
|
||||
@@ -1491,6 +1597,7 @@ async fn artifact_search_uses_only_live_current_head() {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
mode: buzz_search::SearchMode::FullText,
|
||||
sort: buzz_search::SearchSort::Relevance,
|
||||
};
|
||||
let result = svc.search(&query).await.unwrap();
|
||||
assert_eq!(result.hits.len(), 1);
|
||||
|
||||
@@ -255,6 +255,13 @@ run_unit_tests() {
|
||||
handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions \
|
||||
handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds
|
||||
|
||||
# Infra-free bridge search extension parser tests, by exact name: the rest of
|
||||
# api::bridge::postgres_tests needs a database.
|
||||
run_test_step "buzz-relay bridge search extension tests" \
|
||||
cargo test -p buzz-relay --lib -- --exact --nocapture \
|
||||
api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case \
|
||||
api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named
|
||||
|
||||
run_test_step "buzz-relay readiness tests" \
|
||||
cargo test -p buzz-relay --lib readiness::tests:: -- --nocapture
|
||||
|
||||
|
||||
Reference in New Issue
Block a user