Skip to content

Commit ca9bb4b

Browse files
committed
Add esplora tests
1 parent 050ff2d commit ca9bb4b

4 files changed

Lines changed: 152 additions & 14 deletions

File tree

payjoin-cli/contrib/test.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#!/usr/bin/env bash
22
set -e
33

4+
cargo test --locked --package payjoin-cli --verbose --no-default-features --features "v1,v2,_manual-tls,esplora,native-certs"
5+
46
cargo test --locked --package payjoin-cli --verbose --no-default-features --features "v1,v2,_manual-tls,bitcoind,native-certs"

payjoin-cli/src/app/wallet.rs

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,7 @@ mod esplora_backend {
7575
Network::Testnet4 => bdk_wallet::bitcoin::Network::Testnet4,
7676
};
7777

78-
let change_desc = change_descriptor.map(|s| s.to_owned()).unwrap_or_else(|| {
79-
let base = descriptor.strip_suffix("/0/*").unwrap_or(descriptor);
80-
format!("{}1/*", base)
81-
});
78+
let change_desc = super::derive_change_descriptor(descriptor, change_descriptor);
8279

8380
let wallet = BdkWalletInner::create(descriptor.to_owned(), change_desc)
8481
.network(bdk_network)
@@ -452,6 +449,36 @@ mod bitcoind_backend {
452449
pub use BitcoindWallet as BitcoindWalletImpl;
453450
}
454451

452+
#[cfg(feature = "esplora")]
453+
pub(crate) fn parse_network(name: Option<&str>) -> Result<Network> {
454+
use anyhow::anyhow;
455+
match name {
456+
Some("mainnet") | Some("bitcoin") | None => Ok(Network::Bitcoin),
457+
Some("testnet") => Ok(Network::Testnet),
458+
Some("testnet4") => Ok(Network::Testnet4),
459+
Some("signet") => Ok(Network::Signet),
460+
Some("regtest") => Ok(Network::Regtest),
461+
Some(n) => Err(anyhow!("Unknown network: {}", n)),
462+
}
463+
}
464+
465+
/// Derive a change descriptor from the receive descriptor when one is not
466+
/// supplied. Assumes BIP44-family `.../0/*` external chain convention and
467+
/// rewrites the final step to `1/*`. If the descriptor does not end in
468+
/// `/0/*`, returns the original descriptor unchanged (BDK will then use the
469+
/// same descriptor for both keychains).
470+
#[cfg(feature = "esplora")]
471+
pub(crate) fn derive_change_descriptor(descriptor: &str, change: Option<&str>) -> String {
472+
if let Some(c) = change {
473+
return c.to_owned();
474+
}
475+
if let Some(base) = descriptor.strip_suffix("/0/*") {
476+
format!("{}/1/*", base)
477+
} else {
478+
descriptor.to_owned()
479+
}
480+
}
481+
455482
#[cfg(feature = "esplora")]
456483
pub fn create_wallet(config: &super::config::Config) -> Result<Arc<dyn PayjoinWallet>> {
457484
use anyhow::anyhow;
@@ -461,14 +488,7 @@ pub fn create_wallet(config: &super::config::Config) -> Result<Arc<dyn PayjoinWa
461488
.wallet
462489
.as_ref()
463490
.ok_or_else(|| anyhow!("wallet config required. Set --descriptor and --esplora-url"))?;
464-
let network = match wallet_config.network.as_deref() {
465-
Some("mainnet") | Some("bitcoin") | None => payjoin::bitcoin::Network::Bitcoin,
466-
Some("testnet") => payjoin::bitcoin::Network::Testnet,
467-
Some("testnet4") => payjoin::bitcoin::Network::Testnet4,
468-
Some("signet") => payjoin::bitcoin::Network::Signet,
469-
Some("regtest") => payjoin::bitcoin::Network::Regtest,
470-
Some(n) => return Err(anyhow!("Unknown network: {}", n)),
471-
};
491+
let network = parse_network(wallet_config.network.as_deref())?;
472492
let descriptor = wallet_config
473493
.descriptor
474494
.as_ref()
@@ -485,6 +505,62 @@ pub fn create_wallet(config: &super::config::Config) -> Result<Arc<dyn PayjoinWa
485505
)?))
486506
}
487507

508+
#[cfg(all(test, feature = "esplora"))]
509+
mod tests {
510+
use super::*;
511+
512+
#[test]
513+
fn parse_network_known_values() {
514+
assert_eq!(parse_network(None).unwrap(), Network::Bitcoin);
515+
assert_eq!(parse_network(Some("mainnet")).unwrap(), Network::Bitcoin);
516+
assert_eq!(parse_network(Some("bitcoin")).unwrap(), Network::Bitcoin);
517+
assert_eq!(parse_network(Some("testnet")).unwrap(), Network::Testnet);
518+
assert_eq!(parse_network(Some("testnet4")).unwrap(), Network::Testnet4);
519+
assert_eq!(parse_network(Some("signet")).unwrap(), Network::Signet);
520+
assert_eq!(parse_network(Some("regtest")).unwrap(), Network::Regtest);
521+
}
522+
523+
#[test]
524+
fn parse_network_rejects_unknown() {
525+
let err = parse_network(Some("liquid")).unwrap_err();
526+
assert!(err.to_string().contains("Unknown network"));
527+
}
528+
529+
#[test]
530+
fn derive_change_descriptor_uses_explicit_when_provided() {
531+
let receive = "wpkh(tprv.../0/*)";
532+
let explicit = "wpkh(tprv.../1/*)";
533+
assert_eq!(derive_change_descriptor(receive, Some(explicit)), explicit);
534+
}
535+
536+
#[test]
537+
fn derive_change_descriptor_swaps_external_to_internal() {
538+
// The function operates on the raw descriptor string; we only assert
539+
// that a `/0/*` suffix gets rewritten to `/1/*`.
540+
// The helper operates on a raw `.../0/*` suffix; descriptors that
541+
// wrap the key in `wpkh(...)` end in `)` and fall through to the
542+
// passthrough branch (covered by the next test). Here we cover the
543+
// raw-suffix case.
544+
let receive = "[fingerprint/84h/1h/0h]tprvFOO/0/*";
545+
assert_eq!(derive_change_descriptor(receive, None), "[fingerprint/84h/1h/0h]tprvFOO/1/*");
546+
}
547+
548+
#[test]
549+
fn derive_change_descriptor_passthrough_when_no_external_suffix() {
550+
let receive = "wpkh(tprv.../*)";
551+
assert_eq!(derive_change_descriptor(receive, None), receive);
552+
}
553+
554+
#[test]
555+
fn bdk_wallet_new_rejects_invalid_descriptor() {
556+
// Constructing a BdkWallet with garbage should fail before any network
557+
// I/O is attempted, so this test is hermetic.
558+
use super::esplora_backend::BdkWallet;
559+
let res = BdkWallet::new("not a descriptor", None, Network::Regtest, "http://127.0.0.1:1");
560+
assert!(res.is_err());
561+
}
562+
}
563+
488564
#[cfg(all(feature = "bitcoind", not(feature = "esplora")))]
489565
pub fn create_wallet(config: &super::config::Config) -> Result<Arc<dyn PayjoinWallet>> {
490566
use crate::app::wallet::bitcoind_backend::BitcoindWalletImpl;

payjoin-cli/src/cli/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ pub struct Cli {
3535
pub max_fee_rate: Option<FeeRate>,
3636

3737
#[cfg(feature = "esplora")]
38-
#[arg(long, num_args(1), help = "The wallet descriptor, e.g. wpkh(tprv8Z...)")]
38+
#[arg(long, num_args(1), help = "The wallet descriptor")]
3939
pub descriptor: Option<String>,
4040

4141
#[cfg(feature = "esplora")]
42-
#[arg(long, num_args(1), help = "The change descriptor, e.g. wpkh(tprv8Z...)")]
42+
#[arg(long, num_args(1), help = "The change descriptor")]
4343
pub change_descriptor: Option<String>,
4444

4545
#[cfg(feature = "esplora")]

payjoin-cli/tests/cli_esplora.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//! CLI surface tests for the esplora backend.
2+
//!
3+
//! These tests exercise the binary as a subprocess and assert on exit
4+
//! status / stderr only — no network, no bitcoind, no esplora server.
5+
//! Their job is to catch regressions in argument parsing, feature gating,
6+
//! and the missing-config error paths.
7+
8+
#![cfg(feature = "esplora")]
9+
10+
use std::process::Command;
11+
12+
fn bin() -> Command { Command::new(env!("CARGO_BIN_EXE_payjoin-cli")) }
13+
14+
#[test]
15+
fn help_succeeds() {
16+
let output = bin().arg("--help").output().expect("spawn payjoin-cli");
17+
assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr));
18+
let stdout = String::from_utf8_lossy(&output.stdout);
19+
assert!(stdout.contains("Payjoin"));
20+
assert!(stdout.contains("--descriptor"));
21+
assert!(stdout.contains("--esplora-url"));
22+
}
23+
24+
#[test]
25+
fn send_help_succeeds() {
26+
let output = bin().args(["send", "--help"]).output().expect("spawn payjoin-cli");
27+
assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr));
28+
let stdout = String::from_utf8_lossy(&output.stdout);
29+
assert!(stdout.contains("--fee-rate"));
30+
}
31+
32+
#[test]
33+
fn receive_help_succeeds() {
34+
let output = bin().args(["receive", "--help"]).output().expect("spawn payjoin-cli");
35+
assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr));
36+
}
37+
38+
#[test]
39+
fn missing_subcommand_errors() {
40+
let output = bin().output().expect("spawn payjoin-cli");
41+
assert!(!output.status.success());
42+
}
43+
44+
#[test]
45+
fn send_requires_bip21_and_fee_rate() {
46+
let output = bin().arg("send").output().expect("spawn payjoin-cli");
47+
assert!(!output.status.success());
48+
let stderr = String::from_utf8_lossy(&output.stderr);
49+
// clap reports missing required args
50+
assert!(stderr.contains("required") || stderr.contains("USAGE") || stderr.contains("Usage"));
51+
}
52+
53+
#[test]
54+
fn invalid_fee_rate_rejected() {
55+
let output = bin()
56+
.args(["send", "bitcoin:tb1qexample", "--fee-rate", "not-a-number"])
57+
.output()
58+
.expect("spawn payjoin-cli");
59+
assert!(!output.status.success());
60+
}

0 commit comments

Comments
 (0)