test(relay): cover exact #buzz-channel matching against tag decoys

Containment-only matching let a newer event carrying ["x","buzz-channel",<id>] take a limit-1 project or repository lookup. Also reattach the e-tag planner comment and describe the positional check in the tag field docs.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-10-08 21:03:27 -04:00
committed by Hayt
parent 46768baed1
commit fa94d66278
3 changed files with 38 additions and 27 deletions

View File

@@ -89,11 +89,13 @@ pub struct EventQuery {
/// column is NULL (not NIP-33), so this lets identity lookups match before
/// SQL `LIMIT`. Rows of other kinds are left to the caller's post-filter.
pub d_tag_values: Option<Vec<String>>,
/// Restrict results to events with an exact custom tag pair.
/// Uses JSONB containment against `tags` before SQL `LIMIT`.
/// Restrict results to events with an exact custom tag pair: a tag whose
/// first element is the name and second the value, checked positionally
/// (JSONB containment is only the index prefilter) before SQL `LIMIT`.
pub custom_tag: Option<(String, String)>,
/// Restrict results to events with a `t` tag of this value. Uses JSONB
/// containment against `tags` before SQL `LIMIT`, so a per-subject read
/// Restrict results to events with a `t` tag of this value, checked
/// positionally (JSONB containment is only the index prefilter) before
/// SQL `LIMIT`, so a per-subject read
/// (e.g. a kind:44300 team's current version) is not starved by newer
/// events with other `t` values.
pub t_tag: Option<String>,
@@ -860,9 +862,6 @@ async fn fetch_with_e_tag_deadline(
Ok(rows)
}
/// e-tag pushdown as one array-bound containment test instead of an N-way
/// `OR` chain, so planner cost does not scale with the number of referenced
/// ids (the thread aux hop sends one id per reply).
/// Match a tag whose first element is `name` and second is `value` (NIP-01).
///
/// JSONB containment alone ignores element position and multiplicity:
@@ -887,6 +886,9 @@ fn push_exact_tag_filter(
.push(")");
}
/// e-tag pushdown as one array-bound containment test instead of an N-way
/// `OR` chain, so planner cost does not scale with the number of referenced
/// ids (the thread aux hop sends one id per reply).
fn push_e_tag_filter(qb: &mut QueryBuilder<sqlx::Postgres>, col_prefix: &str, e_tags: &[String]) {
let containments: Vec<serde_json::Value> = e_tags
.iter()

View File

@@ -144,25 +144,34 @@ impl Fixture {
#[ignore = "requires Postgres"]
async fn project_filter_survives_artifact_query_hook() {
let f = Fixture::new().await;
let project = EventBuilder::new(Kind::Custom(30621), "")
.tags([
Tag::parse(["d", "review-project"]).unwrap(),
Tag::parse(["buzz-channel", &f.home.to_string()]).unwrap(),
])
.sign_with_keys(&f.owner)
.unwrap();
f.state
.db
.insert_event(f.community, &project, None)
.await
.unwrap();
let filter = json!([{"kinds":[30621],"#buzz-channel":[f.home]}]);
let (status, body) = f.request("/query", filter.clone()).await;
assert!(status.is_success(), "{status}: {body}");
assert_eq!(body.as_array().unwrap().len(), 1);
assert_eq!(body[0]["id"], project.id.to_hex());
let (status, body) = f.request("/count", filter).await;
assert!(status.is_success(), "{status}: {body}");
let home = f.home.to_string();
// 30621 projects and 30617 repositories bind a channel via `buzz-channel`.
for kind in [30621u16, 30617] {
let event = |tag: &[&str], at: u64| {
EventBuilder::new(Kind::Custom(kind), "")
.tags([
Tag::parse(["d", &Uuid::new_v4().to_string()]).unwrap(),
Tag::parse(tag.iter().copied()).unwrap(),
])
.custom_created_at(nostr::Timestamp::from(at))
.sign_with_keys(&f.owner)
.unwrap()
};
let now = nostr::Timestamp::now().as_secs();
let bound = event(&["buzz-channel", &home], now - 10);
// Newer, unbound: `buzz-channel` and the wanted id sit in an unrelated tag.
let decoy = event(&["x", "buzz-channel", &home], now);
for e in [&bound, &decoy] {
f.state.db.insert_event(f.community, e, None).await.unwrap();
}
let filter = json!([{"kinds":[kind],"#buzz-channel":[f.home],"limit":1}]);
let (status, body) = f.request("/query", filter.clone()).await;
assert!(status.is_success(), "{kind} {status}: {body}");
assert_eq!(body.as_array().unwrap().len(), 1, "{kind}: {body}");
assert_eq!(body[0]["id"], bound.id.to_hex(), "{kind}: {body}");
let (status, body) = f.request("/count", filter).await;
assert!(status.is_success(), "{kind} {status}: {body}");
}
}
#[tokio::test]

View File

@@ -1323,7 +1323,7 @@ async fn author_only_kinds_are_unsearchable() {
assert!(
!kinds.contains(&(kind as i32)),
"AUTHOR_ONLY kind:{kind} MUST NOT be searchable — \
schema skip-set is missing this kind. AUTHOR_ONLY_KINDS={AUTHOR_ONLY_KINDS:?}, \
schema exclusion or query fence is missing this kind. AUTHOR_ONLY_KINDS={AUTHOR_ONLY_KINDS:?}, \
hits={kinds:?}",
);
}