Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ members = [
"crates/buzz-workflow",
"crates/buzz-media",
"crates/buzz-cli",
"crates/buzz-client",
"crates/buzz-pairing-cli",
"crates/buzz-sdk",
"crates/buzz-persona",
Expand Down Expand Up @@ -132,6 +133,7 @@ schemars = { version = "1", default-features = false }

# Internal crates
buzz-core = { path = "crates/buzz-core" }
buzz-client = { path = "crates/buzz-client" }
buzz-conformance = { path = "crates/buzz-conformance" }
buzz-db = { path = "crates/buzz-db" }
buzz-auth = { path = "crates/buzz-auth" }
Expand Down
7 changes: 6 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ desktop-e2e-pre-push: _ensure-migrations
cd {{desktop_dir}} && pnpm build:e2e && pnpm exec playwright test --only-changed=origin/main

# Run all checks suitable for CI / pre-push (no infra needed)
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test
ci: check test-unit buzz-client-consumer-check desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test

# ─── Test ─────────────────────────────────────────────────────────────────────

Expand All @@ -292,6 +292,7 @@ test-unit:
cargo nextest run -p buzz-core -p buzz-auth --lib
cargo nextest run -p buzz-voice --lib
cargo nextest run -p buzz-cli
cargo nextest run -p buzz-client
# buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
# They guard the embedded-migrator invariant (exactly the consolidated
# 0001; cutover/backfill stays an operator script, not startup state)
Expand All @@ -317,6 +318,10 @@ test-unit:
./scripts/run-tests.sh unit
fi

# Compile the client exactly as an independent repository would consume it.
buzz-client-consumer-check:
cargo test --manifest-path examples/buzz-client-consumer/Cargo.toml

# Run integration tests only (starts services if needed)
test-integration:
./scripts/run-tests.sh integration
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ chrono = { workspace = true }
# Typed event builders for all write operations
buzz-sdk = { workspace = true }
buzz-core = { workspace = true }
buzz-client = { workspace = true }

# Base64 encoding — NIP-98 event serialization for Authorization header
base64 = "0.22"
Expand Down
43 changes: 43 additions & 0 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ pub struct BlobDescriptor {
}

/// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata).
#[cfg(test)]
pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
let mut tag = vec![
"imeta".to_string(),
Expand Down Expand Up @@ -1837,6 +1838,34 @@ mod retry_policy_tests {
);
}

/// A semantic relay rejection is definitive and must not be retried or
/// translated into an ambiguous delivery outcome.
#[tokio::test]
async fn stored_event_422_is_a_definitive_single_attempt_rejection() {
let (url, attempts) = test_server(|_| {
(
StatusCode::UNPROCESSABLE_ENTITY,
r#"{"error":"invalid event"}"#.to_string(),
)
})
.await;
let client = test_client(&url);
let event = make_stored_event(client.keys());
let err = client.submit_event(event).await.unwrap_err();

assert!(
matches!(
err,
CliError::Relay {
status: 422,
ref body
} if body == "invalid event"
),
"expected definitive relay rejection, got {err:?}"
);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}

/// Spin up a one-shot axum server that handles `GET /info` (and any other GET).
/// Same contract as `test_server` — returns base URL and attempt counter.
async fn get_server<F>(f: F) -> (String, Arc<AtomicU32>)
Expand Down Expand Up @@ -2045,6 +2074,9 @@ mod retry_policy_tests {
let bodies: Arc<std::sync::Mutex<Vec<Vec<u8>>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let bodies2 = bodies.clone();
let auth_headers: Arc<std::sync::Mutex<Vec<String>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let auth_headers2 = auth_headers.clone();

let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
Expand All @@ -2071,6 +2103,13 @@ mod retry_policy_tests {
.unwrap_or(0);
let payload = buf[body_end..].to_vec();
bodies2.lock().unwrap().push(payload);
let request = String::from_utf8_lossy(&buf);
let auth = request
.lines()
.find(|line| line.to_ascii_lowercase().starts_with("authorization:"))
.unwrap_or_default()
.to_string();
auth_headers2.lock().unwrap().push(auth);

if n < 3 {
// Partial body drop.
Expand Down Expand Up @@ -2108,6 +2147,10 @@ mod retry_policy_tests {
captured[1], captured[2],
"attempt 2 and 3 must use identical event bytes"
);
let auth_headers = auth_headers.lock().unwrap();
assert!(auth_headers.iter().all(|header| !header.is_empty()));
assert_ne!(auth_headers[0], auth_headers[1]);
assert_ne!(auth_headers[1], auth_headers[2]);
}

/// `upload_file` uses `with_retry_body` — the full operation including response
Expand Down
65 changes: 65 additions & 0 deletions crates/buzz-cli/src/client_adapter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use std::sync::Arc;
use std::time::Duration;

use buzz_client::{
AuthContext, BuzzClient as ReusableBuzzClient, ClientConfig, ClientError, CommunityEndpoint,
RetryPolicy,
};
use nostr::Keys;

use crate::error::CliError;

pub async fn build_reusable_client(
relay_url: &str,
keys: &Keys,
auth_tag_json: Option<&str>,
) -> Result<ReusableBuzzClient, CliError> {
let endpoint = CommunityEndpoint::parse(relay_url)
.map_err(ClientError::from)
.map_err(map_client_error)?;
let retry = RetryPolicy::new(3, Duration::from_millis(500), Duration::from_millis(1500))
.map_err(map_client_error)?;
let config = ClientConfig::new(
env_duration_secs("BUZZ_TIMEOUT_SECS", 30),
env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15),
retry,
)
.map_err(map_client_error)?;
let auth = match auth_tag_json {
Some(json) => AuthContext::nip_oa(json).map_err(map_client_error)?,
None => AuthContext::signer_only(),
};
ReusableBuzzClient::builder(endpoint, Arc::new(keys.clone()))
.config(config)
.auth_context(auth)
.build()
.await
.map_err(map_client_error)
}

pub fn map_client_error(error: ClientError) -> CliError {
match error {
ClientError::Endpoint(error) => CliError::Usage(error.to_string()),
ClientError::Configuration(message) | ClientError::InvalidInput(message) => {
CliError::Usage(message)
}
ClientError::Authentication(message) => CliError::Auth(message),
ClientError::Signer(error) => CliError::Key(error.to_string()),
ClientError::HttpClient(error) => CliError::Other(error.to_string()),
ClientError::Network(error) => CliError::Network(error),
ClientError::Relay { status, reason, .. } => CliError::Relay {
status,
body: reason,
},
ClientError::Serialization(error) => CliError::Other(error.to_string()),
}
}

fn env_duration_secs(name: &str, default: u64) -> Duration {
std::env::var(name)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|seconds| *seconds > 0)
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(default))
}
Loading