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:
Kalvin C
2026-10-15 13:27:49 -07:00
committed by GitHub
parent fea02d174d
commit cdfc38ecbd
9 changed files with 178 additions and 15 deletions

View File

@@ -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,

View File

@@ -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. |

View File

@@ -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!(

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;

View File

@@ -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 ");

View File

@@ -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);

View File

@@ -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