mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
test(relay): cover NIP-09 deletions of kind:44300 versions
Moves the shared deletion e2e helpers from e2e_author_only_deletion into tests/support so both suites reuse them, and checks that a deletion of a version needs k=44300, is author-only on every read surface and live, and leaves the version gone. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -25,40 +25,17 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_test_client::{BuzzTestClient, RelayMessage, TestClientError};
|
||||
use nostr::{EventBuilder, EventId, Filter, Keys, Kind, Tag};
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
use nostr::{EventBuilder, EventId, Filter, Keys, Kind};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
|
||||
#[path = "support/author_only_deletion.rs"]
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
const KIND_EVENT_REMINDER: u16 = 30300;
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string())
|
||||
}
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
relay_url()
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn sub_id(name: &str) -> String {
|
||||
format!("e2e-aod-{name}-{}", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
fn http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("failed to build HTTP client")
|
||||
}
|
||||
|
||||
fn tag(parts: &[&str]) -> Tag {
|
||||
Tag::parse(parts.to_vec()).expect("valid tag")
|
||||
}
|
||||
|
||||
fn build_reminder(keys: &Keys) -> nostr::Event {
|
||||
EventBuilder::new(
|
||||
Kind::Custom(KIND_EVENT_REMINDER),
|
||||
@@ -72,42 +49,6 @@ fn build_reminder(keys: &Keys) -> nostr::Event {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn build_deletion(keys: &Keys, tags: Vec<Tag>) -> nostr::Event {
|
||||
build_deletion_with_reason(keys, tags, "")
|
||||
}
|
||||
|
||||
fn build_deletion_with_reason(keys: &Keys, tags: Vec<Tag>, reason: &str) -> nostr::Event {
|
||||
EventBuilder::new(Kind::EventDeletion, reason)
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn submit(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) {
|
||||
let resp = client
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", keys.public_key().to_hex())
|
||||
.json(event)
|
||||
.send()
|
||||
.await
|
||||
.expect("submit event");
|
||||
let ok = resp.status().is_success();
|
||||
let body: Value = resp.json().await.expect("parse response");
|
||||
if ok {
|
||||
(
|
||||
body["accepted"].as_bool().unwrap_or(false),
|
||||
body["message"].as_str().unwrap_or("").to_string(),
|
||||
)
|
||||
} else {
|
||||
(false, body["error"].as_str().unwrap_or("").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn submit_ok(client: &Client, keys: &Keys, event: &nostr::Event) {
|
||||
let (accepted, msg) = submit(client, keys, event).await;
|
||||
assert!(accepted, "setup event rejected: {msg}");
|
||||
}
|
||||
|
||||
/// Store a reminder as `author`, then delete it with a `k`-tagged kind:5
|
||||
/// whose reason is a unique search term. Returns the reminder and deletion.
|
||||
async fn store_deleted_reminder(client: &Client, author: &Keys) -> (nostr::Event, nostr::Event) {
|
||||
@@ -132,130 +73,6 @@ fn search_term(author: &Keys) -> String {
|
||||
format!("aodreason{}", &author.public_key().to_hex()[..16])
|
||||
}
|
||||
|
||||
/// Filters that each match only the deletion `store_deleted_reminder` left:
|
||||
/// by kind, mixed kinds, and id. The id filter is the kindless COUNT case;
|
||||
/// broader kindless filters can match `#p`-gated kinds, so the relay closes
|
||||
/// them before any read runs.
|
||||
fn deletion_filters(author: &Keys, deletion: EventId) -> Vec<Filter> {
|
||||
vec![
|
||||
Filter::new()
|
||||
.kind(Kind::EventDeletion)
|
||||
.author(author.public_key()),
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::TextNote])
|
||||
.author(author.public_key()),
|
||||
Filter::new().id(deletion),
|
||||
]
|
||||
}
|
||||
|
||||
async fn http_query_ids(client: &Client, reader: &Keys, filter: &Filter) -> Vec<String> {
|
||||
let resp = client
|
||||
.post(format!("{}/query", relay_http_url()))
|
||||
.header("X-Pubkey", reader.public_key().to_hex())
|
||||
.json(&vec![filter])
|
||||
.send()
|
||||
.await
|
||||
.expect("query events");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"query failed: {}",
|
||||
resp.status()
|
||||
);
|
||||
resp.json::<Vec<Value>>()
|
||||
.await
|
||||
.expect("parse query response")
|
||||
.iter()
|
||||
.filter_map(|e| e["id"].as_str().map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn http_count(client: &Client, reader: &Keys, filter: &Filter) -> u64 {
|
||||
let resp = client
|
||||
.post(format!("{}/count", relay_http_url()))
|
||||
.header("X-Pubkey", reader.public_key().to_hex())
|
||||
.json(&vec![filter])
|
||||
.send()
|
||||
.await
|
||||
.expect("count events");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"count failed: {}",
|
||||
resp.status()
|
||||
);
|
||||
let body: Value = resp.json().await.expect("parse count response");
|
||||
body["count"].as_u64().expect("count field")
|
||||
}
|
||||
|
||||
async fn ws_query_ids(ws: &mut BuzzTestClient, filter: &Filter) -> Vec<EventId> {
|
||||
let sid = sub_id("req");
|
||||
ws.subscribe(&sid, vec![filter.clone()]).await.expect("REQ");
|
||||
let events = ws
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("EOSE");
|
||||
ws.close_subscription(&sid).await.expect("CLOSE");
|
||||
events.into_iter().map(|e| e.id).collect()
|
||||
}
|
||||
|
||||
async fn ws_count(ws: &mut BuzzTestClient, filter: &Filter) -> u64 {
|
||||
let sid = sub_id("count");
|
||||
ws.send_raw(&serde_json::json!(["COUNT", sid, filter]))
|
||||
.await
|
||||
.expect("COUNT");
|
||||
loop {
|
||||
match ws
|
||||
.recv_event(Duration::from_secs(5))
|
||||
.await
|
||||
.expect("COUNT reply")
|
||||
{
|
||||
RelayMessage::Count {
|
||||
subscription_id,
|
||||
count,
|
||||
} if subscription_id == sid => return count,
|
||||
RelayMessage::Closed {
|
||||
subscription_id,
|
||||
message,
|
||||
} if subscription_id == sid => panic!("COUNT closed: {message}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every historical read surface returns the deletion to `reader` iff
|
||||
/// `visible`: WS REQ, WS COUNT, HTTP `/query` and HTTP `/count`, by author
|
||||
/// and by id.
|
||||
async fn assert_deletion_visibility(
|
||||
reader: &Keys,
|
||||
author: &Keys,
|
||||
deletion: EventId,
|
||||
visible: bool,
|
||||
) {
|
||||
let client = http_client();
|
||||
let mut ws = BuzzTestClient::connect(&relay_url(), reader)
|
||||
.await
|
||||
.expect("connect reader");
|
||||
let expected = u64::from(visible);
|
||||
let mut mismatches = Vec::new();
|
||||
for filter in deletion_filters(author, deletion) {
|
||||
let observed = [
|
||||
("WS REQ", ws_query_ids(&mut ws, &filter).await.len() as u64),
|
||||
("WS COUNT", ws_count(&mut ws, &filter).await),
|
||||
(
|
||||
"HTTP /query",
|
||||
http_query_ids(&client, reader, &filter).await.len() as u64,
|
||||
),
|
||||
("HTTP /count", http_count(&client, reader, &filter).await),
|
||||
];
|
||||
for (surface, n) in observed {
|
||||
if n != expected {
|
||||
mismatches.push(format!("{surface} {filter:?}: {n}, expected {expected}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
|
||||
ws.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Whether the relay's `search_tsv` indexes kind:5 content. The desired
|
||||
/// schema (`schema/schema.sql`, which CI applies) does; an empty database
|
||||
/// initialized through migration 0008 gets a positive allowlist that doesn't.
|
||||
@@ -832,28 +649,6 @@ async fn receives_any(ws: &mut BuzzTestClient, sid: &str) -> bool {
|
||||
!live_event_ids(ws, sid).await.is_empty()
|
||||
}
|
||||
|
||||
/// IDs of the events subscription `sid` receives until 2s pass with no
|
||||
/// message. A dropped connection or a `CLOSED` for `sid` fails the test, so
|
||||
/// a dead subscription can never pass for one that saw nothing.
|
||||
async fn live_event_ids(ws: &mut BuzzTestClient, sid: &str) -> Vec<EventId> {
|
||||
let mut ids = Vec::new();
|
||||
loop {
|
||||
match ws.recv_event(Duration::from_secs(2)).await {
|
||||
Ok(RelayMessage::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
}) if subscription_id == sid => ids.push(event.id),
|
||||
Ok(RelayMessage::Closed {
|
||||
subscription_id,
|
||||
message,
|
||||
}) if subscription_id == sid => panic!("{sid} closed: {message}"),
|
||||
Ok(_) => {}
|
||||
Err(TestClientError::Timeout) => return ids,
|
||||
Err(e) => panic!("{sid} receive failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deleting a private deletion would store a public event naming it, so the
|
||||
/// author is refused (NIP-09 gives such a deletion no effect), everyone else
|
||||
/// gets the shared denial, and no reader but the author ever sees either.
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
//! and the HTTP bridge query, with the owner as the positive control;
|
||||
//! - exclusion from full-text search, for the author too;
|
||||
//! - per-subject `limit: 1` current-version reads and composite
|
||||
//! `(until, before_id)` history paging across tied timestamps.
|
||||
//! `(until, before_id)` history paging across tied timestamps;
|
||||
//! - NIP-09 deletions of a version: the `k` tag is required, the deletion is
|
||||
//! as private as the version on every read surface and live, and the
|
||||
//! version is gone afterwards.
|
||||
//!
|
||||
//! The relay does not decrypt; content here is an opaque placeholder.
|
||||
//!
|
||||
@@ -27,6 +30,9 @@ use buzz_test_client::{BuzzTestClient, RelayMessage};
|
||||
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[path = "support/author_only_deletion.rs"]
|
||||
mod aod;
|
||||
|
||||
const INSTRUCTIONS_VERSION_KIND: u16 = 44300;
|
||||
const MAX_CONTENT_BYTES: usize = 218_548;
|
||||
const CIPHERTEXT: &str = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
@@ -685,3 +691,131 @@ async fn test_instructions_version_unsearchable_for_author() {
|
||||
);
|
||||
ws.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Publish a version as `owner`, then delete it with a `k`-tagged kind:5.
|
||||
/// Returns the version and the accepted deletion.
|
||||
async fn store_deleted_version(owner: &Keys) -> (nostr::Event, nostr::Event) {
|
||||
let client = aod::http_client();
|
||||
let version = agent_version(owner, &new_agent(), now());
|
||||
aod::submit_ok(&client, owner, &version).await;
|
||||
let deletion = aod::build_deletion(
|
||||
owner,
|
||||
vec![
|
||||
aod::tag(&["e", &version.id.to_hex()]),
|
||||
aod::tag(&["k", &INSTRUCTIONS_VERSION_KIND.to_string()]),
|
||||
],
|
||||
);
|
||||
aod::submit_ok(&client, owner, &deletion).await;
|
||||
(version, deletion)
|
||||
}
|
||||
|
||||
/// A deletion of a version reaches its owner and nobody else over WS REQ,
|
||||
/// `ids` lookup, WS COUNT, HTTP `/query` and HTTP `/count`, and the version
|
||||
/// itself is gone for the owner afterwards.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_instructions_version_deletion_reads_are_author_only() {
|
||||
let owner = Keys::generate();
|
||||
let (version, deletion) = store_deleted_version(&owner).await;
|
||||
aod::assert_deletion_visibility(&owner, &owner, deletion.id, true).await;
|
||||
aod::assert_deletion_visibility(&Keys::generate(), &owner, deletion.id, false).await;
|
||||
|
||||
let by_id = Filter::new().id(version.id);
|
||||
assert!(
|
||||
aod::http_query_ids(&aod::http_client(), &owner, &by_id)
|
||||
.await
|
||||
.is_empty(),
|
||||
"the deleted version must be gone over HTTP for its owner"
|
||||
);
|
||||
let mut ws = BuzzTestClient::connect(&relay_url(), &owner)
|
||||
.await
|
||||
.expect("connect");
|
||||
assert!(
|
||||
aod::ws_query_ids(&mut ws, &by_id).await.is_empty(),
|
||||
"the deleted version must be gone over WS for its owner"
|
||||
);
|
||||
assert_eq!(aod::ws_count(&mut ws, &by_id).await, 0);
|
||||
ws.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Subscribed while the version still exists, the owner receives its
|
||||
/// deletion live and another reader does not.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_instructions_version_deletion_live_fanout_is_author_only() {
|
||||
let client = aod::http_client();
|
||||
let owner = Keys::generate();
|
||||
let version = agent_version(&owner, &new_agent(), now());
|
||||
aod::submit_ok(&client, &owner, &version).await;
|
||||
|
||||
let live = Filter::new()
|
||||
.kind(Kind::EventDeletion)
|
||||
.author(owner.public_key());
|
||||
let mut subscribers = Vec::new();
|
||||
for keys in [owner.clone(), Keys::generate()] {
|
||||
let mut ws = BuzzTestClient::connect(&relay_url(), &keys)
|
||||
.await
|
||||
.expect("connect");
|
||||
let sid = sub_id("deletion-live");
|
||||
ws.subscribe(&sid, vec![live.clone()]).await.expect("REQ");
|
||||
assert!(ws
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("EOSE")
|
||||
.is_empty());
|
||||
subscribers.push((ws, sid));
|
||||
}
|
||||
|
||||
let deletion = aod::build_deletion(
|
||||
&owner,
|
||||
vec![
|
||||
aod::tag(&["e", &version.id.to_hex()]),
|
||||
aod::tag(&["k", &INSTRUCTIONS_VERSION_KIND.to_string()]),
|
||||
],
|
||||
);
|
||||
aod::submit_ok(&client, &owner, &deletion).await;
|
||||
|
||||
for (i, (mut ws, sid)) in subscribers.into_iter().enumerate() {
|
||||
let delivered = aod::live_event_ids(&mut ws, &sid)
|
||||
.await
|
||||
.contains(&deletion.id);
|
||||
assert_eq!(
|
||||
delivered,
|
||||
i == 0,
|
||||
"live delivery to subscriber {i} (0 = owner)"
|
||||
);
|
||||
ws.disconnect().await.expect("disconnect");
|
||||
}
|
||||
}
|
||||
|
||||
/// A deletion of a version must carry `["k","44300"]`: one without it, or
|
||||
/// with another kind, is rejected whether the version is live or already
|
||||
/// deleted.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_instructions_version_deletion_requires_k_tag() {
|
||||
let client = aod::http_client();
|
||||
let owner = Keys::generate();
|
||||
let version = agent_version(&owner, &new_agent(), now());
|
||||
aod::submit_ok(&client, &owner, &version).await;
|
||||
let target = aod::tag(&["e", &version.id.to_hex()]);
|
||||
let k = INSTRUCTIONS_VERSION_KIND.to_string();
|
||||
|
||||
for tags in [
|
||||
vec![target.clone()],
|
||||
vec![target.clone(), aod::tag(&["k", "1"])],
|
||||
vec![target.clone(), aod::tag(&["k", &format!("0{k}")])],
|
||||
] {
|
||||
let (accepted, msg) =
|
||||
aod::submit(&client, &owner, &aod::build_deletion(&owner, tags.clone())).await;
|
||||
assert!(!accepted, "deletion {tags:?} must be rejected (got: {msg})");
|
||||
}
|
||||
|
||||
let (deleted, _) = store_deleted_version(&owner).await;
|
||||
let untagged = aod::build_deletion(&owner, vec![aod::tag(&["e", &deleted.id.to_hex()])]);
|
||||
let (accepted, msg) = aod::submit(&client, &owner, &untagged).await;
|
||||
assert!(
|
||||
!accepted,
|
||||
"an untagged deletion of a deleted version must be rejected (got: {msg})"
|
||||
);
|
||||
}
|
||||
|
||||
219
crates/buzz-test-client/tests/support/author_only_deletion.rs
Normal file
219
crates/buzz-test-client/tests/support/author_only_deletion.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! Shared helpers for NIP-09 deletion e2e tests of author-only kinds. Each
|
||||
//! test binary covering a kind in `AUTHOR_ONLY_KINDS` includes this module.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_test_client::{BuzzTestClient, RelayMessage, TestClientError};
|
||||
use nostr::{EventBuilder, EventId, Filter, Keys, Kind, Tag};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string())
|
||||
}
|
||||
|
||||
pub fn relay_http_url() -> String {
|
||||
relay_url()
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn sub_id(name: &str) -> String {
|
||||
format!("e2e-aod-{name}-{}", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("failed to build HTTP client")
|
||||
}
|
||||
|
||||
pub fn tag(parts: &[&str]) -> Tag {
|
||||
Tag::parse(parts.to_vec()).expect("valid tag")
|
||||
}
|
||||
|
||||
pub fn build_deletion(keys: &Keys, tags: Vec<Tag>) -> nostr::Event {
|
||||
build_deletion_with_reason(keys, tags, "")
|
||||
}
|
||||
|
||||
pub fn build_deletion_with_reason(keys: &Keys, tags: Vec<Tag>, reason: &str) -> nostr::Event {
|
||||
EventBuilder::new(Kind::EventDeletion, reason)
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn submit(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) {
|
||||
let resp = client
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", keys.public_key().to_hex())
|
||||
.json(event)
|
||||
.send()
|
||||
.await
|
||||
.expect("submit event");
|
||||
let ok = resp.status().is_success();
|
||||
let body: Value = resp.json().await.expect("parse response");
|
||||
if ok {
|
||||
(
|
||||
body["accepted"].as_bool().unwrap_or(false),
|
||||
body["message"].as_str().unwrap_or("").to_string(),
|
||||
)
|
||||
} else {
|
||||
(false, body["error"].as_str().unwrap_or("").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn submit_ok(client: &Client, keys: &Keys, event: &nostr::Event) {
|
||||
let (accepted, msg) = submit(client, keys, event).await;
|
||||
assert!(accepted, "setup event rejected: {msg}");
|
||||
}
|
||||
|
||||
/// Filters that each match only the deletion `store_deleted_reminder` left:
|
||||
/// by kind, mixed kinds, and id. The id filter is the kindless COUNT case;
|
||||
/// broader kindless filters can match `#p`-gated kinds, so the relay closes
|
||||
/// them before any read runs.
|
||||
pub fn deletion_filters(author: &Keys, deletion: EventId) -> Vec<Filter> {
|
||||
vec![
|
||||
Filter::new()
|
||||
.kind(Kind::EventDeletion)
|
||||
.author(author.public_key()),
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::TextNote])
|
||||
.author(author.public_key()),
|
||||
Filter::new().id(deletion),
|
||||
]
|
||||
}
|
||||
|
||||
pub async fn http_query_ids(client: &Client, reader: &Keys, filter: &Filter) -> Vec<String> {
|
||||
let resp = client
|
||||
.post(format!("{}/query", relay_http_url()))
|
||||
.header("X-Pubkey", reader.public_key().to_hex())
|
||||
.json(&vec![filter])
|
||||
.send()
|
||||
.await
|
||||
.expect("query events");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"query failed: {}",
|
||||
resp.status()
|
||||
);
|
||||
resp.json::<Vec<Value>>()
|
||||
.await
|
||||
.expect("parse query response")
|
||||
.iter()
|
||||
.filter_map(|e| e["id"].as_str().map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn http_count(client: &Client, reader: &Keys, filter: &Filter) -> u64 {
|
||||
let resp = client
|
||||
.post(format!("{}/count", relay_http_url()))
|
||||
.header("X-Pubkey", reader.public_key().to_hex())
|
||||
.json(&vec![filter])
|
||||
.send()
|
||||
.await
|
||||
.expect("count events");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"count failed: {}",
|
||||
resp.status()
|
||||
);
|
||||
let body: Value = resp.json().await.expect("parse count response");
|
||||
body["count"].as_u64().expect("count field")
|
||||
}
|
||||
|
||||
pub async fn ws_query_ids(ws: &mut BuzzTestClient, filter: &Filter) -> Vec<EventId> {
|
||||
let sid = sub_id("req");
|
||||
ws.subscribe(&sid, vec![filter.clone()]).await.expect("REQ");
|
||||
let events = ws
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("EOSE");
|
||||
ws.close_subscription(&sid).await.expect("CLOSE");
|
||||
events.into_iter().map(|e| e.id).collect()
|
||||
}
|
||||
|
||||
pub async fn ws_count(ws: &mut BuzzTestClient, filter: &Filter) -> u64 {
|
||||
let sid = sub_id("count");
|
||||
ws.send_raw(&serde_json::json!(["COUNT", sid, filter]))
|
||||
.await
|
||||
.expect("COUNT");
|
||||
loop {
|
||||
match ws
|
||||
.recv_event(Duration::from_secs(5))
|
||||
.await
|
||||
.expect("COUNT reply")
|
||||
{
|
||||
RelayMessage::Count {
|
||||
subscription_id,
|
||||
count,
|
||||
} if subscription_id == sid => return count,
|
||||
RelayMessage::Closed {
|
||||
subscription_id,
|
||||
message,
|
||||
} if subscription_id == sid => panic!("COUNT closed: {message}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every historical read surface returns the deletion to `reader` iff
|
||||
/// `visible`: WS REQ, WS COUNT, HTTP `/query` and HTTP `/count`, by author
|
||||
/// and by id.
|
||||
pub async fn assert_deletion_visibility(
|
||||
reader: &Keys,
|
||||
author: &Keys,
|
||||
deletion: EventId,
|
||||
visible: bool,
|
||||
) {
|
||||
let client = http_client();
|
||||
let mut ws = BuzzTestClient::connect(&relay_url(), reader)
|
||||
.await
|
||||
.expect("connect reader");
|
||||
let expected = u64::from(visible);
|
||||
let mut mismatches = Vec::new();
|
||||
for filter in deletion_filters(author, deletion) {
|
||||
let observed = [
|
||||
("WS REQ", ws_query_ids(&mut ws, &filter).await.len() as u64),
|
||||
("WS COUNT", ws_count(&mut ws, &filter).await),
|
||||
(
|
||||
"HTTP /query",
|
||||
http_query_ids(&client, reader, &filter).await.len() as u64,
|
||||
),
|
||||
("HTTP /count", http_count(&client, reader, &filter).await),
|
||||
];
|
||||
for (surface, n) in observed {
|
||||
if n != expected {
|
||||
mismatches.push(format!("{surface} {filter:?}: {n}, expected {expected}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(mismatches.is_empty(), "{}", mismatches.join("\n"));
|
||||
ws.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// IDs of the events subscription `sid` receives until 2s pass with no
|
||||
/// message. A dropped connection or a `CLOSED` for `sid` fails the test, so
|
||||
/// a dead subscription can never pass for one that saw nothing.
|
||||
pub async fn live_event_ids(ws: &mut BuzzTestClient, sid: &str) -> Vec<EventId> {
|
||||
let mut ids = Vec::new();
|
||||
loop {
|
||||
match ws.recv_event(Duration::from_secs(2)).await {
|
||||
Ok(RelayMessage::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
}) if subscription_id == sid => ids.push(event.id),
|
||||
Ok(RelayMessage::Closed {
|
||||
subscription_id,
|
||||
message,
|
||||
}) if subscription_id == sid => panic!("{sid} closed: {message}"),
|
||||
Ok(_) => {}
|
||||
Err(TestClientError::Timeout) => return ids,
|
||||
Err(e) => panic!("{sid} receive failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user