cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit ce5be2dc7e548e6a44eda80acc19ee1adac29002
parent 9531b64e862e0c632576edeb2866c2d6aedb195e
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 21:21:41 +0000

tests: prove radrootsd proxy publish

- add one-shot local JSON-RPC proxy server test helper
- exercise listing publish without dry-run through radrootsd_proxy
- assert SDK daemon idempotency and signed event forwarding
- verify the SDK outbox reaches terminal non-pending state

Diffstat:
Mtests/target_cli.rs | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 244 insertions(+), 0 deletions(-)

diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1,9 +1,11 @@ mod support; use std::fs; +use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; use radroots_events::RadrootsNostrEventPtr; use radroots_events::ids::{ @@ -86,6 +88,150 @@ impl RelayFetchServer { } } +struct RadrootsdProxyJsonRpcServer { + endpoint: String, + handle: JoinHandle<Value>, +} + +impl RadrootsdProxyJsonRpcServer { + fn once(expected_token: &'static str) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind radrootsd proxy"); + listener + .set_nonblocking(true) + .expect("radrootsd proxy nonblocking"); + let endpoint = format!("http://{}", listener.local_addr().expect("proxy addr")); + let handle = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match listener.accept() { + Ok((stream, _)) => { + return handle_radrootsd_proxy_connection(stream, expected_token); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < deadline, + "timed out waiting for radrootsd proxy request" + ); + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept radrootsd proxy connection: {error}"), + } + } + }); + Self { endpoint, handle } + } + + fn endpoint(&self) -> &str { + self.endpoint.as_str() + } + + fn join(self) -> Value { + self.handle.join().expect("radrootsd proxy server join") + } +} + +fn handle_radrootsd_proxy_connection(mut stream: TcpStream, expected_token: &str) -> Value { + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 1024]; + let body_start = loop { + let read = stream.read(&mut buffer).expect("read radrootsd proxy"); + assert!(read > 0, "radrootsd proxy request closed before headers"); + bytes.extend_from_slice(&buffer[..read]); + if let Some(index) = http_body_start(&bytes) { + break index; + } + }; + let headers = String::from_utf8(bytes[..body_start].to_vec()).expect("headers utf8"); + let content_length = http_content_length(headers.as_str()); + while bytes.len() < body_start + content_length { + let read = stream.read(&mut buffer).expect("read radrootsd proxy body"); + assert!(read > 0, "radrootsd proxy request closed before body"); + bytes.extend_from_slice(&buffer[..read]); + } + let body = String::from_utf8(bytes[body_start..body_start + content_length].to_vec()) + .expect("body utf8"); + assert!( + headers + .to_ascii_lowercase() + .contains(format!("authorization: bearer {expected_token}").as_str()), + "radrootsd proxy request missing expected bearer auth: {headers}" + ); + let request: Value = serde_json::from_str(body.as_str()).expect("radrootsd proxy json"); + assert_eq!(request["jsonrpc"], "2.0"); + assert_eq!(request["method"], "publish.event"); + let event = &request["params"]["event"]; + let relays = request["params"]["relays"] + .as_array() + .cloned() + .unwrap_or_default(); + let relay_results = relays + .iter() + .map(|relay| { + json!({ + "relay_url": relay, + "source": "request", + "attempted": true, + "outcome_kind": "accepted" + }) + }) + .collect::<Vec<_>>(); + let response = json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { + "deduplicated": false, + "job": { + "job_id": "cli-proxy-job-1", + "status": "delivery_satisfied", + "terminal": true, + "delivery_satisfied": true, + "event_id": event["id"], + "pubkey": event["pubkey"], + "event_kind": event["kind"], + "relay_policy": request["params"]["relay_policy"], + "delivery_policy": request["params"]["delivery_policy"], + "relay_count": relay_results.len(), + "acknowledged_count": relay_results.len(), + "retryable_count": 0, + "terminal_count": 0, + "requested_at_ms": 1_700_000_000_000_i64, + "completed_at_ms": 1_700_000_000_001_i64, + "relays": relay_results + } + } + }); + let response_body = response.to_string(); + let raw_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", + response_body.len() + ); + stream + .write_all(raw_response.as_bytes()) + .expect("write radrootsd proxy response"); + request +} + +fn http_body_start(bytes: &[u8]) -> Option<usize> { + bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) +} + +fn http_content_length(headers: &str) -> usize { + headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + Some(value.trim().parse::<usize>().expect("content length")) + } else { + None + } + }) + .expect("content-length header") +} + fn handle_relay_fetch_connection(stream: TcpStream, events: Vec<RadrootsNostrEvent>) { let mut websocket = tungstenite::accept(stream).expect("accept fetch websocket"); let subscription_id = read_relay_req_subscription_id(&mut websocket); @@ -1417,6 +1563,104 @@ fn radrootsd_proxy_listing_publish_update_and_archive_dry_run_without_direct_rel } #[test] +fn radrootsd_proxy_listing_publish_non_dry_run_uses_local_jsonrpc_server() { + let sandbox = RadrootsCliSandbox::new(); + let proxy = RadrootsdProxyJsonRpcServer::once("proxy_test_token"); + let token_file = radrootsd_proxy_token_file(&sandbox); + sandbox.write_app_config( + format!( + r#"[publish] +transport = "radrootsd_proxy" + +[publish.radrootsd_proxy] +url = "{}" +token_file = "{}" +"#, + toml_string(proxy.endpoint()), + toml_string(token_file.display().to_string().as_str()) + ) + .as_str(), + ); + sandbox.json_success(&["--format", "json", "account", "create"]); + let farm = sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Proxy Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let listing_file = create_listing_draft(&sandbox, "radrootsd-proxy-live"); + make_listing_publishable( + &listing_file, + farm["result"]["config"]["farm_d_tag"] + .as_str() + .expect("farm d tag"), + ); + + let output = sandbox + .command() + .args([ + "--format", + "json", + "--approval-token", + "approve", + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]) + .output() + .expect("run proxy listing publish"); + let request = proxy.join(); + let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); + + assert!( + output.status.success(), + "stderr `{}` stdout `{}`", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + assert_eq!(value["operation_id"], "listing.publish"); + assert_eq!(value["result"]["state"], "published"); + assert_eq!(value["result"]["source"], "SDK listing publish ยท local key"); + assert_eq!(value["result"]["dry_run"], false); + assert_eq!( + request["params"]["event"]["id"], + value["result"]["event_id"] + ); + assert_eq!( + request["params"]["relays"] + .as_array() + .expect("relays") + .len(), + 0 + ); + assert!( + request["params"]["idempotency_key"] + .as_str() + .expect("daemon idempotency key") + .contains("-1-") + ); + + let status = sandbox.json_success(&["--format", "json", "sync", "status", "get"]); + assert_eq!( + status["result"]["source"], + "SDK canonical event store and outbox" + ); + assert_eq!(status["result"]["queue"]["pending_count"], 0); + assert_eq!(status["result"]["queue"]["ready_signed_count"], 0); + assert_eq!(status["result"]["queue"]["publishing_count"], 0); + assert_eq!(status["result"]["queue"]["retryable_count"], 0); + assert_eq!(status["result"]["queue"]["failed_terminal_count"], 0); +} + +#[test] fn listing_update_publish_attempts_direct_relay_with_approval() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]);