From d8973fdd2057fbac3e7d827f2e6c077129e6ce2e Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Mon, 1 Jun 2026 09:08:14 -0400 Subject: [PATCH 1/3] geolocation/cli: show probe codes in user get targets table The 'probe' column of the Targets table in 'doublezero geolocation user get' rendered the geoprobe pubkey. Resolve it to the probe code via list_geo_probes, falling back to the pubkey when the probe is not found. JSON output gains a 'probe' code field while retaining the canonical 'geoprobe_pk'. --- .../src/user/get.rs | 111 +++++++++++++++++- 1 file changed, 108 insertions(+), 3 deletions(-) diff --git a/crates/doublezero-geolocation-cli/src/user/get.rs b/crates/doublezero-geolocation-cli/src/user/get.rs index 57d4eb9fd..07404fa9e 100644 --- a/crates/doublezero-geolocation-cli/src/user/get.rs +++ b/crates/doublezero-geolocation-cli/src/user/get.rs @@ -5,7 +5,9 @@ use doublezero_geolocation::state::geolocation_user::{ GeoLocationTargetType, GeolocationBillingConfig, }; use doublezero_program_common::serializer; -use doublezero_sdk::geolocation::geolocation_user::get::GetGeolocationUserCommand; +use doublezero_sdk::geolocation::{ + geo_probe::list::ListGeoProbeCommand, geolocation_user::get::GetGeolocationUserCommand, +}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; use std::io::Write; @@ -50,8 +52,10 @@ struct TargetDisplay { pub port: u16, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub target_signing_pubkey: Pubkey, - #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] #[tabled(rename = "probe")] + pub probe: String, + #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] + #[tabled(skip)] pub geoprobe_pk: Pubkey, } @@ -68,6 +72,8 @@ impl GetGeolocationUserCliCommand { pubkey_or_code: self.user, })?; + let probes = client.list_geo_probes(ListGeoProbeCommand)?; + let targets: Vec = user .targets .iter() @@ -78,11 +84,16 @@ impl GetGeolocationUserCliCommand { } GeoLocationTargetType::Inbound => ("-".to_string(), 0), }; + let probe = probes + .get(&t.geoprobe_pk) + .map(|p| p.code.clone()) + .unwrap_or_else(|| t.geoprobe_pk.to_string()); TargetDisplay { target_type: t.target_type.to_string(), ip, port, target_signing_pubkey: t.target_pk, + probe, geoprobe_pk: t.geoprobe_pk, } }) @@ -161,6 +172,7 @@ mod tests { use doublezero_cli_core::testing::{block_on, cli_context_default_for_tests}; use doublezero_geolocation::state::{ accounttype::AccountType, + geo_probe::GeoProbe, geolocation_user::{ FlatPerEpochConfig, GeoLocationTargetType, GeolocationPaymentStatus, GeolocationTarget, GeolocationUser, GeolocationUserStatus, @@ -168,7 +180,27 @@ mod tests { }; use mockall::predicate; use solana_sdk::pubkey::Pubkey; - use std::net::Ipv4Addr; + use std::{collections::HashMap, net::Ipv4Addr}; + + fn make_probes(probe_pk: Pubkey, code: &str) -> HashMap { + let mut probes = HashMap::new(); + probes.insert( + probe_pk, + GeoProbe { + account_type: AccountType::GeoProbe, + owner: Pubkey::default(), + exchange_pk: Pubkey::default(), + public_ip: Ipv4Addr::new(10, 0, 0, 1), + location_offset_port: 8923, + code: code.to_string(), + parent_devices: vec![], + metrics_publisher_pk: Pubkey::default(), + reference_count: 0, + target_update_count: 0, + }, + ); + probes + } fn make_user(code: &str, targets: Vec) -> GeolocationUser { GeolocationUser { @@ -211,6 +243,11 @@ mod tests { })) .returning(move |_| Ok((user_pk, user.clone()))); + let probes = make_probes(probe_pk, "ams-probe-01"); + client + .expect_list_geo_probes() + .returning(move |_| Ok(probes.clone())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -236,6 +273,52 @@ mod tests { assert!(has_row("target_count", "1")); assert!(output_str.contains("Targets:")); assert!(output_str.contains("8.8.8.8")); + // probe column shows the code, not the pubkey + assert!(output_str.contains("ams-probe-01")); + assert!(!output_str.contains(&probe_pk.to_string())); + } + + #[test] + fn test_cli_geolocation_user_get_unknown_probe_falls_back_to_pubkey() { + let mut client = MockGeoCliCommand::new(); + let user_pk = Pubkey::from_str_const("BmrLoL9jzYo4yiPUsFhYFU8hgE3CD3Npt8tgbqvneMyB"); + let probe_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + + let user = make_user( + "geo-user-01", + vec![GeolocationTarget { + target_type: GeoLocationTargetType::Outbound, + ip_address: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 8923, + target_pk: Pubkey::default(), + geoprobe_pk: probe_pk, + }], + ); + + client + .expect_get_geolocation_user() + .with(predicate::eq(GetGeolocationUserCommand { + pubkey_or_code: user_pk.to_string(), + })) + .returning(move |_| Ok((user_pk, user.clone()))); + + client + .expect_list_geo_probes() + .returning(|_| Ok(HashMap::new())); + + let ctx = cli_context_default_for_tests(); + let mut output = Vec::new(); + let res = block_on( + GetGeolocationUserCliCommand { + user: user_pk.to_string(), + json: false, + json_compact: false, + } + .execute(&ctx, &client, &mut output), + ); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains(&probe_pk.to_string())); } #[test] @@ -252,6 +335,10 @@ mod tests { })) .returning(move |_| Ok((user_pk, user.clone()))); + client + .expect_list_geo_probes() + .returning(|_| Ok(HashMap::new())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -288,6 +375,10 @@ mod tests { })) .returning(move |_| Ok((user_pk, user.clone()))); + client + .expect_list_geo_probes() + .returning(|_| Ok(HashMap::new())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -327,6 +418,11 @@ mod tests { })) .returning(move |_| Ok((user_pk, user.clone()))); + let probes = make_probes(probe_pk, "ams-probe-01"); + client + .expect_list_geo_probes() + .returning(move |_| Ok(probes.clone())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -347,5 +443,14 @@ mod tests { assert_eq!(json["target_count"].as_u64().unwrap(), 1); assert_eq!(json["targets"].as_array().unwrap().len(), 1); assert_eq!(json["targets"][0]["ip"].as_str().unwrap(), "8.8.8.8"); + // JSON carries the resolved probe code plus the canonical pubkey + assert_eq!( + json["targets"][0]["probe"].as_str().unwrap(), + "ams-probe-01" + ); + assert_eq!( + json["targets"][0]["geoprobe_pk"].as_str().unwrap(), + probe_pk.to_string() + ); } } From b61abd54f1218deba39b4c2deed4340c75e0b3d2 Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Mon, 1 Jun 2026 09:24:48 -0400 Subject: [PATCH 2/3] geolocation/cli: resolve exchange code in probe get 'doublezero geolocation probe get' rendered the exchange as a raw pubkey, inconsistent with 'probe list' which already shows the exchange code. Resolve exchange_pk to its code via list_exchanges with a pubkey fallback, matching the list verb. --- CHANGELOG.md | 2 + .../src/probe/get.rs | 91 +++++++++++++++++-- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce0ebbe8..b8c0d8b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file. - SDK (Rust) - Add `DZClient::from_context` and `GeoClient::from_context`, which build clients directly from a resolved RFC-20 `CliContext` instead of re-reading `~/.config/doublezero/cli/config.yml` and re-applying moniker conversion. The context already carries the fully resolved ledger RPC/WS URLs and program IDs, so these constructors consume them verbatim, making the context the single source of truth and removing the double-resolution the binary previously incurred. Keypair precedence is preserved exactly (CLI flag > `DOUBLEZERO_KEYPAIR` > stdin > context keypair path > default): the raw `--keypair` flag is passed as the highest-precedence source and the context keypair path is used only as the low-precedence fallback, so the env var still wins. The new constructors and their `doublezero-cli-core` dependency are gated behind a `cli-context` cargo feature so non-CLI SDK consumers (controlplane, telemetry, e2e) keep a dependency-light default build. `DZClient::new` / `GeoClient::new` are unchanged for callers that do not build a `CliContext` (e.g. `controlplane/doublezero-admin`). - CLI + - geolocation `user get`: Show probe code, rather than probe pubkey in target list. + - geolocation `probe get`: Show exchange code, rather than exchange pubkeys. - Honor the build-configured default environment (`Testnet` by default, `MainnetBeta` under the `default-mainnet-beta` feature) when neither `--env` nor a persisted `config.yml` selects one. The RFC-20 context-build previously fell back to `Environment::default()`, which is always `Devnet` regardless of the build, so a testnet build with no config silently targeted Devnet's ledger URLs and program IDs. The binary now resolves the fallback through the new `doublezero_sdk::default_environment()`, matching the legacy `DZClient::new` defaults (`default_program_id`, `ClientConfig::default`) which already key off the compiled-in environment ([#3810](https://github.com/malbeclabs/doublezero/pull/3810)) - Construct the serviceability and geolocation SDK clients in the `doublezero` binary via `DZClient::from_context` / `GeoClient::from_context`, replacing the legacy `DZClient::new(Option, ...)` bridge. The binary no longer round-trips the already-resolved `CliContext` values back through the SDK's config-file re-resolution. No user-facing command, flag, or output change. - Restore environment-moniker support for the `--program-id` and `--geo-program-id` global flags. The context-build resolved both flags with a raw `parse::()`, so a moniker (e.g. `--geo-program-id testnet`) failed to parse, was silently dropped, and the binary fell back to the environment default. Both flags now accept monikers in their full (`mainnet-beta`, `testnet`, `devnet`, `local`) and short (`m`, `t`, `d`, `l`) forms, resolving to the matching program ID; a literal pubkey still passes through. A value that is neither a known moniker nor a valid pubkey is now a hard error instead of being silently ignored. `convert_program_moniker` is broadened to cover all four environments (previously only `devnet`/`testnet`), matching `convert_geo_program_moniker`. diff --git a/crates/doublezero-geolocation-cli/src/probe/get.rs b/crates/doublezero-geolocation-cli/src/probe/get.rs index a13f2ad82..fc2c1fe63 100644 --- a/crates/doublezero-geolocation-cli/src/probe/get.rs +++ b/crates/doublezero-geolocation-cli/src/probe/get.rs @@ -36,8 +36,7 @@ struct GeoProbeGetDisplay { pub code: String, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, - #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] - pub exchange: Pubkey, + pub exchange: String, pub public_ip: Ipv4Addr, pub port: u16, #[serde(serialize_with = "serialize_pubkey_vec_as_string_array")] @@ -64,6 +63,12 @@ impl GetGeoProbeCliCommand { pubkey_or_code: self.probe, })?; + let exchanges = client.list_exchanges()?; + let exchange = exchanges + .get(&probe.exchange_pk) + .map(|ex| ex.code.clone()) + .unwrap_or_else(|| probe.exchange_pk.to_string()); + let parent_devices_display = format!( "[{}]", probe @@ -77,7 +82,7 @@ impl GetGeoProbeCliCommand { account: pubkey, code: probe.code, owner: probe.owner, - exchange: probe.exchange_pk, + exchange, public_ip: probe.public_ip, port: probe.location_offset_port, parent_devices: probe.parent_devices, @@ -112,9 +117,34 @@ mod tests { use crate::client::MockGeoCliCommand; use doublezero_cli_core::testing::{block_on, cli_context_default_for_tests}; use doublezero_geolocation::state::{accounttype::AccountType, geo_probe::GeoProbe}; + use doublezero_sdk::{AccountType as SvcAccountType, Exchange, ExchangeStatus}; use mockall::predicate; use solana_sdk::pubkey::Pubkey; - use std::net::Ipv4Addr; + use std::{collections::HashMap, net::Ipv4Addr}; + + fn make_exchanges(exchange_pk: Pubkey, code: &str) -> HashMap { + let mut exchanges = HashMap::new(); + exchanges.insert( + exchange_pk, + Exchange { + account_type: SvcAccountType::Exchange, + owner: exchange_pk, + index: 0, + bump_seed: 0, + reference_count: 0, + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 0.0, + lng: 0.0, + bgp_community: 0, + unused: 0, + status: ExchangeStatus::Activated, + code: code.to_string(), + name: code.to_string(), + }, + ); + exchanges + } fn setup_client() -> (MockGeoCliCommand, Pubkey, Pubkey, Pubkey, Pubkey) { let client = MockGeoCliCommand::new(); @@ -161,6 +191,11 @@ mod tests { .expect_get_geo_probe() .returning(move |_| Err(eyre::eyre!("not found"))); + let exchanges = make_exchanges(exchange_pk, "ams"); + client + .expect_list_exchanges() + .returning(move || Ok(exchanges.clone())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -192,7 +227,9 @@ mod tests { assert!(has_row("account", &probe_pk.to_string())); assert!(has_row("code", "ams-probe-01")); assert!(has_row("owner", &owner_pk.to_string())); - assert!(has_row("exchange", &exchange_pk.to_string())); + // exchange column shows the code, not the pubkey + assert!(has_row("exchange", "ams")); + assert!(!output_str.contains(&exchange_pk.to_string())); assert!(has_row("public_ip", "10.0.0.1")); assert!(has_row("port", "8923")); assert!(has_row("signing_pubkey", &metrics_pk.to_string())); @@ -212,6 +249,11 @@ mod tests { })) .returning(move |_| Ok((probe_pk, probe.clone()))); + let exchanges = make_exchanges(exchange_pk, "ams"); + client + .expect_list_exchanges() + .returning(move || Ok(exchanges.clone())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -228,7 +270,7 @@ mod tests { assert_eq!(json["account"].as_str().unwrap(), probe_pk.to_string()); assert_eq!(json["code"].as_str().unwrap(), "ams-probe-01"); assert_eq!(json["owner"].as_str().unwrap(), owner_pk.to_string()); - assert_eq!(json["exchange"].as_str().unwrap(), exchange_pk.to_string()); + assert_eq!(json["exchange"].as_str().unwrap(), "ams"); assert_eq!(json["public_ip"].as_str().unwrap(), "10.0.0.1"); assert_eq!(json["port"].as_u64().unwrap(), 8923); let parents = json["parent_devices"].as_array().unwrap(); @@ -254,6 +296,11 @@ mod tests { })) .returning(move |_| Ok((probe_pk, probe.clone()))); + let exchanges = make_exchanges(exchange_pk, "ams"); + client + .expect_list_exchanges() + .returning(move || Ok(exchanges.clone())); + let ctx = cli_context_default_for_tests(); let mut output = Vec::new(); let res = block_on( @@ -271,6 +318,38 @@ mod tests { let json: serde_json::Value = serde_json::from_str(trimmed).unwrap(); assert_eq!(json["account"].as_str().unwrap(), probe_pk.to_string()); assert_eq!(json["code"].as_str().unwrap(), "ams-probe-01"); + assert_eq!(json["exchange"].as_str().unwrap(), "ams"); assert_eq!(json["parent_devices"].as_array().unwrap().len(), 1); } + + #[test] + fn test_cli_geo_probe_get_unknown_exchange_falls_back_to_pubkey() { + let (mut client, probe_pk, owner_pk, exchange_pk, metrics_pk) = setup_client(); + let probe = make_probe(owner_pk, exchange_pk, metrics_pk, vec![]); + + client + .expect_get_geo_probe() + .with(predicate::eq(GetGeoProbeCommand { + pubkey_or_code: probe_pk.to_string(), + })) + .returning(move |_| Ok((probe_pk, probe.clone()))); + + client + .expect_list_exchanges() + .returning(|| Ok(HashMap::new())); + + let ctx = cli_context_default_for_tests(); + let mut output = Vec::new(); + let res = block_on( + GetGeoProbeCliCommand { + probe: probe_pk.to_string(), + json: false, + json_compact: false, + } + .execute(&ctx, &client, &mut output), + ); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains(&exchange_pk.to_string())); + } } From e470d1082a393fec0302339f8d0d2dd9397de410 Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Mon, 1 Jun 2026 10:46:08 -0400 Subject: [PATCH 3/3] geolocation/cli: address review on code resolution - probe get: keep the canonical exchange pubkey in JSON and expose the resolved code as a separate exchange_code field, so machine-readable output stays backward compatible (mirrors the user get target change) - user get / probe get: treat code resolution as best-effort; an error listing probes/exchanges now falls back to the pubkey instead of failing the command, matching the existing not-found fallback --- .../src/probe/get.rs | 55 +++++++++++++++++-- .../src/user/get.rs | 51 ++++++++++++++++- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/crates/doublezero-geolocation-cli/src/probe/get.rs b/crates/doublezero-geolocation-cli/src/probe/get.rs index fc2c1fe63..124e09a79 100644 --- a/crates/doublezero-geolocation-cli/src/probe/get.rs +++ b/crates/doublezero-geolocation-cli/src/probe/get.rs @@ -36,7 +36,11 @@ struct GeoProbeGetDisplay { pub code: String, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, - pub exchange: String, + #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] + #[tabled(skip)] + pub exchange: Pubkey, + #[tabled(rename = "exchange")] + pub exchange_code: String, pub public_ip: Ipv4Addr, pub port: u16, #[serde(serialize_with = "serialize_pubkey_vec_as_string_array")] @@ -63,8 +67,11 @@ impl GetGeoProbeCliCommand { pubkey_or_code: self.probe, })?; - let exchanges = client.list_exchanges()?; - let exchange = exchanges + let exchanges = client.list_exchanges().unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to list exchanges; showing exchange pubkey"); + Default::default() + }); + let exchange_code = exchanges .get(&probe.exchange_pk) .map(|ex| ex.code.clone()) .unwrap_or_else(|| probe.exchange_pk.to_string()); @@ -82,7 +89,8 @@ impl GetGeoProbeCliCommand { account: pubkey, code: probe.code, owner: probe.owner, - exchange, + exchange: probe.exchange_pk, + exchange_code, public_ip: probe.public_ip, port: probe.location_offset_port, parent_devices: probe.parent_devices, @@ -270,7 +278,9 @@ mod tests { assert_eq!(json["account"].as_str().unwrap(), probe_pk.to_string()); assert_eq!(json["code"].as_str().unwrap(), "ams-probe-01"); assert_eq!(json["owner"].as_str().unwrap(), owner_pk.to_string()); - assert_eq!(json["exchange"].as_str().unwrap(), "ams"); + // exchange retains the canonical pubkey; the code is a separate field + assert_eq!(json["exchange"].as_str().unwrap(), exchange_pk.to_string()); + assert_eq!(json["exchange_code"].as_str().unwrap(), "ams"); assert_eq!(json["public_ip"].as_str().unwrap(), "10.0.0.1"); assert_eq!(json["port"].as_u64().unwrap(), 8923); let parents = json["parent_devices"].as_array().unwrap(); @@ -318,7 +328,8 @@ mod tests { let json: serde_json::Value = serde_json::from_str(trimmed).unwrap(); assert_eq!(json["account"].as_str().unwrap(), probe_pk.to_string()); assert_eq!(json["code"].as_str().unwrap(), "ams-probe-01"); - assert_eq!(json["exchange"].as_str().unwrap(), "ams"); + assert_eq!(json["exchange"].as_str().unwrap(), exchange_pk.to_string()); + assert_eq!(json["exchange_code"].as_str().unwrap(), "ams"); assert_eq!(json["parent_devices"].as_array().unwrap().len(), 1); } @@ -352,4 +363,36 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert!(output_str.contains(&exchange_pk.to_string())); } + + #[test] + fn test_cli_geo_probe_get_exchange_list_error_falls_back_to_pubkey() { + let (mut client, probe_pk, owner_pk, exchange_pk, metrics_pk) = setup_client(); + let probe = make_probe(owner_pk, exchange_pk, metrics_pk, vec![]); + + client + .expect_get_geo_probe() + .with(predicate::eq(GetGeoProbeCommand { + pubkey_or_code: probe_pk.to_string(), + })) + .returning(move |_| Ok((probe_pk, probe.clone()))); + + // A failure to list exchanges must not abort the command; it falls back to the pubkey. + client + .expect_list_exchanges() + .returning(|| Err(eyre::eyre!("rpc error"))); + + let ctx = cli_context_default_for_tests(); + let mut output = Vec::new(); + let res = block_on( + GetGeoProbeCliCommand { + probe: probe_pk.to_string(), + json: false, + json_compact: false, + } + .execute(&ctx, &client, &mut output), + ); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains(&exchange_pk.to_string())); + } } diff --git a/crates/doublezero-geolocation-cli/src/user/get.rs b/crates/doublezero-geolocation-cli/src/user/get.rs index 07404fa9e..b39d0c656 100644 --- a/crates/doublezero-geolocation-cli/src/user/get.rs +++ b/crates/doublezero-geolocation-cli/src/user/get.rs @@ -72,7 +72,12 @@ impl GetGeolocationUserCliCommand { pubkey_or_code: self.user, })?; - let probes = client.list_geo_probes(ListGeoProbeCommand)?; + let probes = client + .list_geo_probes(ListGeoProbeCommand) + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "failed to list geo probes; showing probe pubkey"); + Default::default() + }); let targets: Vec = user .targets @@ -321,6 +326,50 @@ mod tests { assert!(output_str.contains(&probe_pk.to_string())); } + #[test] + fn test_cli_geolocation_user_get_probe_list_error_falls_back_to_pubkey() { + let mut client = MockGeoCliCommand::new(); + let user_pk = Pubkey::from_str_const("BmrLoL9jzYo4yiPUsFhYFU8hgE3CD3Npt8tgbqvneMyB"); + let probe_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + + let user = make_user( + "geo-user-01", + vec![GeolocationTarget { + target_type: GeoLocationTargetType::Outbound, + ip_address: Ipv4Addr::new(8, 8, 8, 8), + location_offset_port: 8923, + target_pk: Pubkey::default(), + geoprobe_pk: probe_pk, + }], + ); + + client + .expect_get_geolocation_user() + .with(predicate::eq(GetGeolocationUserCommand { + pubkey_or_code: user_pk.to_string(), + })) + .returning(move |_| Ok((user_pk, user.clone()))); + + // A failure to list probes must not abort the command; it falls back to the pubkey. + client + .expect_list_geo_probes() + .returning(|_| Err(eyre::eyre!("rpc error"))); + + let ctx = cli_context_default_for_tests(); + let mut output = Vec::new(); + let res = block_on( + GetGeolocationUserCliCommand { + user: user_pk.to_string(), + json: false, + json_compact: false, + } + .execute(&ctx, &client, &mut output), + ); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains(&probe_pk.to_string())); + } + #[test] fn test_cli_geolocation_user_get_json() { let mut client = MockGeoCliCommand::new();