Skip to main content

scope_cli/cli/
address_book.rs

1//! # Address Book Command
2//!
3//! This module implements the `scope address-book` command for managing
4//! watched addresses and viewing aggregated address book data.
5//!
6//! ## Usage
7//!
8//! ```bash
9//! # Add an address to address book
10//! scope address-book add 0x742d... --label "Main Wallet"
11//!
12//! # List watched addresses
13//! scope address-book list
14//!
15//! # Remove an address
16//! scope address-book remove 0x742d...
17//!
18//! # View address book summary
19//! scope address-book summary
20//! ```
21
22use crate::cli::output::Output;
23use crate::{note, outln};
24use clap::{Args, Subcommand};
25use scope::chains::{ChainClientFactory, native_symbol};
26use scope::config::{Config, OutputFormat};
27use scope::error::Result;
28use std::collections::HashMap;
29use std::path::PathBuf;
30
31// Re-export domain types so existing `crate::cli::address_book::X` paths keep working.
32pub use scope::domain::address_book::{
33    AddressBook, AddressBookSummary, AddressSummary, ChainBalance, TokenSummary, WatchedAddress,
34    resolve_address_book_input,
35};
36
37/// Arguments for the address book management command.
38#[derive(Debug, Clone, Args)]
39pub struct AddressBookArgs {
40    /// AddressBook subcommand to execute.
41    #[command(subcommand)]
42    pub command: AddressBookCommands,
43
44    /// Override output format.
45    #[arg(short, long, global = true, value_name = "FORMAT")]
46    pub format: Option<OutputFormat>,
47}
48
49/// AddressBook subcommands.
50#[derive(Debug, Clone, Subcommand)]
51pub enum AddressBookCommands {
52    /// Add an address to the address book.
53    Add(AddArgs),
54
55    /// Remove an address from the address book.
56    Remove(RemoveArgs),
57
58    /// List all watched addresses.
59    List,
60
61    /// Show address book summary with balances.
62    Summary(SummaryArgs),
63}
64
65/// Arguments for adding an address.
66#[derive(Debug, Clone, Args)]
67#[command(after_help = "\x1b[1mExamples:\x1b[0m
68  scope address-book add 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --label \"Main Wallet\"
69  scope address-book add 0x742d... --chain ethereum --tags hot,trading
70  scope ab add DRpbCBMx...TDt1v --chain solana --label \"SOL Vault\"")]
71pub struct AddArgs {
72    /// The address to add.
73    #[arg(value_name = "ADDRESS")]
74    pub address: String,
75
76    /// Human-readable label for the address.
77    #[arg(short, long)]
78    pub label: Option<String>,
79
80    /// Blockchain network for this address.
81    #[arg(short, long, default_value = scope::chains::DEFAULT_CHAIN)]
82    pub chain: String,
83
84    /// Tags for categorization.
85    #[arg(short, long, value_delimiter = ',')]
86    pub tags: Vec<String>,
87}
88
89/// Arguments for removing an address.
90#[derive(Debug, Clone, Args)]
91#[command(after_help = "\x1b[1mExamples:\x1b[0m
92  scope address-book remove 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
93  scope ab remove DRpbCBMx...TDt1v")]
94pub struct RemoveArgs {
95    /// The address to remove.
96    #[arg(value_name = "ADDRESS")]
97    pub address: String,
98}
99
100/// Arguments for address book summary.
101#[derive(Debug, Clone, Args)]
102#[command(after_help = "\x1b[1mExamples:\x1b[0m
103  scope address-book summary
104  scope address-book summary --chain ethereum --include-tokens
105  scope ab summary --tag trading --report portfolio.md")]
106pub struct SummaryArgs {
107    /// Filter by chain.
108    #[arg(short, long)]
109    pub chain: Option<String>,
110
111    /// Filter by tag.
112    #[arg(short, long)]
113    pub tag: Option<String>,
114
115    /// Include token balances.
116    #[arg(long)]
117    pub include_tokens: bool,
118
119    /// Generate and save a markdown report to the specified path.
120    #[arg(long, value_name = "PATH")]
121    pub report: Option<std::path::PathBuf>,
122}
123
124/// Executes the address book command.
125pub async fn run(
126    args: AddressBookArgs,
127    config: &Config,
128    clients: &dyn ChainClientFactory,
129    out: &Output,
130) -> Result<()> {
131    let data_dir = config.data_dir();
132    let format = args.format.unwrap_or(config.output.format);
133
134    match args.command {
135        AddressBookCommands::Add(add_args) => run_add(add_args, &data_dir, out).await,
136        AddressBookCommands::Remove(remove_args) => run_remove(remove_args, &data_dir, out).await,
137        AddressBookCommands::List => run_list(&data_dir, format, out).await,
138        AddressBookCommands::Summary(summary_args) => {
139            run_summary(summary_args, &data_dir, format, clients, out).await
140        }
141    }
142}
143
144async fn run_add(args: AddArgs, data_dir: &PathBuf, out: &Output) -> Result<()> {
145    tracing::info!(address = %args.address, "Adding address to address book");
146
147    let mut address_book = AddressBook::load(data_dir)?;
148
149    let watched = WatchedAddress {
150        address: args.address.clone(),
151        label: args.label.clone(),
152        chain: args.chain.clone(),
153        tags: args.tags.clone(),
154        added_at: std::time::SystemTime::now()
155            .duration_since(std::time::UNIX_EPOCH)
156            .unwrap_or_default()
157            .as_secs(),
158    };
159
160    address_book.add_address(watched)?;
161    address_book.save(data_dir)?;
162
163    outln!(
164        out,
165        "Added {} to address book{}",
166        args.address,
167        args.label
168            .map(|l| format!(" as '{}'", l))
169            .unwrap_or_default()
170    )?;
171
172    Ok(())
173}
174
175async fn run_remove(args: RemoveArgs, data_dir: &PathBuf, out: &Output) -> Result<()> {
176    tracing::info!(address = %args.address, "Removing address from address book");
177
178    let mut address_book = AddressBook::load(data_dir)?;
179    let removed = address_book.remove_address(&args.address)?;
180
181    if removed {
182        address_book.save(data_dir)?;
183        outln!(out, "Removed {} from address book", args.address)?;
184    } else {
185        outln!(out, "Address not found in address book: {}", args.address)?;
186    }
187
188    Ok(())
189}
190
191async fn run_list(data_dir: &std::path::Path, format: OutputFormat, out: &Output) -> Result<()> {
192    let address_book = AddressBook::load(data_dir)?;
193
194    if address_book.addresses.is_empty() {
195        outln!(
196            out,
197            "Address book is empty. Add addresses with 'scope address-book add <address>'"
198        )?;
199        return Ok(());
200    }
201
202    match format {
203        OutputFormat::Json => {
204            let json = serde_json::to_string_pretty(&address_book.addresses)?;
205            outln!(out, "{}", json)?;
206        }
207        OutputFormat::Csv => {
208            outln!(out, "address,label,chain,tags")?;
209            for addr in &address_book.addresses {
210                outln!(
211                    out,
212                    "{},{},{},{}",
213                    addr.address,
214                    addr.label.as_deref().unwrap_or(""),
215                    addr.chain,
216                    addr.tags.join(";")
217                )?;
218            }
219        }
220        OutputFormat::Table => {
221            outln!(out, "Address Book")?;
222            outln!(out, "===================")?;
223            for addr in &address_book.addresses {
224                outln!(
225                    out,
226                    "  {} ({}) - {}{}",
227                    addr.address,
228                    addr.chain,
229                    addr.label.as_deref().unwrap_or("No label"),
230                    if addr.tags.is_empty() {
231                        String::new()
232                    } else {
233                        format!(" [{}]", addr.tags.join(", "))
234                    }
235                )?;
236            }
237            outln!(out, "\nTotal: {} addresses", address_book.addresses.len())?;
238        }
239        OutputFormat::Markdown => {
240            let mut md = "# Address Book\n\n".to_string();
241            md.push_str("| Address | Chain | Label | Tags |\n|---------|-------|-------|------|\n");
242            for addr in &address_book.addresses {
243                let tags = if addr.tags.is_empty() {
244                    "-".to_string()
245                } else {
246                    addr.tags.join(", ")
247                };
248                md.push_str(&format!(
249                    "| `{}` | {} | {} | {} |\n",
250                    addr.address,
251                    addr.chain,
252                    addr.label.as_deref().unwrap_or("-"),
253                    tags
254                ));
255            }
256            md.push_str(&format!(
257                "\n**Total:** {} addresses\n",
258                address_book.addresses.len()
259            ));
260            outln!(out, "{}", md)?;
261        }
262    }
263
264    Ok(())
265}
266
267async fn run_summary(
268    args: SummaryArgs,
269    data_dir: &std::path::Path,
270    format: OutputFormat,
271    clients: &dyn ChainClientFactory,
272    out: &Output,
273) -> Result<()> {
274    let address_book = AddressBook::load(data_dir)?;
275
276    if address_book.addresses.is_empty() {
277        outln!(
278            out,
279            "Address book is empty. Add addresses with 'scope address-book add <address>'"
280        )?;
281        return Ok(());
282    }
283
284    // Filter addresses
285    let filtered: Vec<_> = address_book
286        .addresses
287        .iter()
288        .filter(|a| args.chain.as_ref().is_none_or(|c| &a.chain == c))
289        .filter(|a| args.tag.as_ref().is_none_or(|t| a.tags.contains(t)))
290        .collect();
291
292    // Fetch balances for each address
293    let mut address_summaries = Vec::new();
294    let mut balances_by_chain: HashMap<String, ChainBalance> = HashMap::new();
295
296    for watched in &filtered {
297        let (balance, tokens) = fetch_address_balance(
298            &watched.address,
299            &watched.chain,
300            clients,
301            args.include_tokens,
302            out,
303        )
304        .await?;
305
306        // Aggregate chain balances
307        if let Some(chain_bal) = balances_by_chain.get_mut(&watched.chain) {
308            // For simplicity, we're showing individual balances, not aggregating
309            // A more complete implementation would sum balances
310            let _ = chain_bal;
311        } else {
312            balances_by_chain.insert(
313                watched.chain.clone(),
314                ChainBalance {
315                    native_balance: balance.clone(),
316                    symbol: native_symbol(&watched.chain).to_string(),
317                    usd: None,
318                },
319            );
320        }
321
322        address_summaries.push(AddressSummary {
323            address: watched.address.clone(),
324            label: watched.label.clone(),
325            chain: watched.chain.clone(),
326            balance,
327            usd: None,
328            tokens,
329        });
330    }
331
332    let summary = AddressBookSummary {
333        address_count: filtered.len(),
334        balances_by_chain,
335        total_usd: None,
336        addresses: address_summaries,
337    };
338
339    match format {
340        OutputFormat::Json => {
341            let json = serde_json::to_string_pretty(&summary)?;
342            outln!(out, "{}", json)?;
343        }
344        OutputFormat::Csv => {
345            outln!(out, "address,label,chain,balance,usd")?;
346            for addr in &summary.addresses {
347                outln!(
348                    out,
349                    "{},{},{},{},{}",
350                    addr.address,
351                    addr.label.as_deref().unwrap_or(""),
352                    addr.chain,
353                    addr.balance,
354                    addr.usd.map_or(String::new(), |u| format!("{:.2}", u))
355                )?;
356            }
357        }
358        OutputFormat::Table => {
359            outln!(out, "Address Book Summary")?;
360            outln!(out, "=================")?;
361            outln!(out, "Addresses: {}", summary.address_count)?;
362            outln!(out)?;
363
364            for addr in &summary.addresses {
365                outln!(
366                    out,
367                    "  {} ({}) - {} {}",
368                    addr.label.as_deref().unwrap_or(&addr.address),
369                    addr.chain,
370                    addr.balance,
371                    addr.usd.map_or(String::new(), |u| format!("(${:.2})", u))
372                )?;
373
374                // Show token balances
375                for token in &addr.tokens {
376                    let addr_short = scope::display::text::abbreviate(&token.contract_address);
377                    let symbol = token.symbol.as_deref().unwrap_or(&addr_short);
378                    outln!(out, "    └─ {} {}", token.balance, symbol)?;
379                }
380            }
381
382            if let Some(total) = summary.total_usd {
383                outln!(out)?;
384                outln!(out, "Total Value: ${:.2}", total)?;
385            }
386        }
387        OutputFormat::Markdown => {
388            let md = address_book_summary_to_markdown(&summary);
389            outln!(out, "{}", md)?;
390        }
391    }
392
393    // Generate report if requested
394    if let Some(ref report_path) = args.report {
395        let md = address_book_summary_to_markdown(&summary);
396        std::fs::write(report_path, md)?;
397        out.note_saved("Report", report_path.display());
398    }
399
400    Ok(())
401}
402
403/// Generates a markdown report for address book summary.
404fn address_book_summary_to_markdown(summary: &AddressBookSummary) -> String {
405    let mut md = format!(
406        "# Address Book Report\n\n\
407        **Generated:** {}  \n\
408        **Addresses:** {}  \n\n",
409        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
410        summary.address_count
411    );
412
413    if let Some(total) = summary.total_usd {
414        md.push_str(&format!("**Total Value (USD):** ${:.2}  \n\n", total));
415    }
416
417    md.push_str("## Allocation by Chain\n\n");
418    md.push_str(
419        "| Chain | Native Balance | Symbol | USD |\n|-------|----------------|--------|-----|\n",
420    );
421    for (chain, bal) in &summary.balances_by_chain {
422        let usd = bal
423            .usd
424            .map(|u| format!("${:.2}", u))
425            .unwrap_or_else(|| "-".to_string());
426        md.push_str(&format!(
427            "| {} | {} | {} | {} |\n",
428            chain, bal.native_balance, bal.symbol, usd
429        ));
430    }
431
432    md.push_str("\n## Addresses\n\n");
433    md.push_str("| Address | Label | Chain | Balance | USD | Tokens |\n");
434    md.push_str("|---------|-------|-------|---------|-----|--------|\n");
435    for addr in &summary.addresses {
436        let label = addr.label.as_deref().unwrap_or("-");
437        let usd = addr
438            .usd
439            .map(|u| format!("${:.2}", u))
440            .unwrap_or_else(|| "-".to_string());
441        let token_list: String = addr
442            .tokens
443            .iter()
444            .map(|t| t.symbol.as_deref().unwrap_or(&t.contract_address))
445            .take(3)
446            .collect::<Vec<_>>()
447            .join(", ");
448        let tokens_display = if addr.tokens.len() > 3 {
449            format!("{} (+{})", token_list, addr.tokens.len() - 3)
450        } else {
451            token_list
452        };
453        md.push_str(&format!(
454            "| `{}` | {} | {} | {} | {} | {} |\n",
455            addr.address,
456            label,
457            addr.chain,
458            addr.balance,
459            usd,
460            if tokens_display.is_empty() {
461                "-"
462            } else {
463                &tokens_display
464            }
465        ));
466    }
467
468    md.push_str(&scope::display::report::report_footer());
469    md
470}
471
472/// Fetches the balance for an address on the specified chain using the factory.
473async fn fetch_address_balance(
474    address: &str,
475    chain: &str,
476    clients: &dyn ChainClientFactory,
477    _include_tokens: bool,
478    out: &Output,
479) -> std::io::Result<(String, Vec<TokenSummary>)> {
480    let client = match clients.create_chain_client(chain) {
481        Ok(c) => c,
482        Err(e) => {
483            note!(out, "⚠ Unsupported chain: {}", chain);
484            tracing::debug!(error = %e, chain = %chain, "Failed to create chain client");
485            return Ok(("Error".to_string(), vec![]));
486        }
487    };
488
489    // Fetch native balance
490    let native_balance = match client.get_balance(address).await {
491        Ok(bal) => bal.formatted,
492        Err(e) => {
493            note!(out, "⚠ Could not fetch balance for {}", address);
494            tracing::debug!(error = %e, address = %address, "Failed to fetch balance");
495            "Error".to_string()
496        }
497    };
498
499    // Always fetch token balances for address book summary
500    let tokens = match client.get_token_balances(address).await {
501        Ok(token_bals) => token_bals
502            .into_iter()
503            .map(|tb| TokenSummary {
504                contract_address: tb.token.contract_address,
505                balance: tb.formatted_balance,
506                decimals: tb.token.decimals,
507                symbol: Some(tb.token.symbol),
508            })
509            .collect(),
510        Err(e) => {
511            note!(out, "⚠ Token balances unavailable");
512            tracing::debug!(error = %e, "Could not fetch token balances");
513            vec![]
514        }
515    };
516
517    Ok((native_balance, tokens))
518}
519
520// ============================================================================
521// Unit Tests
522// ============================================================================
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use tempfile::TempDir;
528
529    fn quiet() -> crate::cli::output::Output {
530        crate::cli::output::Output::capture().0
531    }
532
533    fn create_test_address_book() -> AddressBook {
534        AddressBook {
535            addresses: vec![
536                WatchedAddress {
537                    address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
538                    label: Some("Main Wallet".to_string()),
539                    chain: "ethereum".to_string(),
540                    tags: vec!["personal".to_string()],
541                    added_at: 1700000000,
542                },
543                WatchedAddress {
544                    address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
545                    label: None,
546                    chain: "polygon".to_string(),
547                    tags: vec![],
548                    added_at: 1700000001,
549                },
550            ],
551        }
552    }
553
554    #[test]
555    fn test_address_book_args_parsing() {
556        use clap::Parser;
557
558        #[derive(Parser)]
559        struct TestCli {
560            #[command(flatten)]
561            args: AddressBookArgs,
562        }
563
564        let cli = TestCli::try_parse_from(["test", "list"]).unwrap();
565        assert!(matches!(cli.args.command, AddressBookCommands::List));
566    }
567
568    #[test]
569    fn test_address_book_add_args_parsing() {
570        use clap::Parser;
571
572        #[derive(Parser)]
573        struct TestCli {
574            #[command(flatten)]
575            args: AddressBookArgs,
576        }
577
578        let cli = TestCli::try_parse_from([
579            "test",
580            "add",
581            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
582            "--label",
583            "My Wallet",
584            "--chain",
585            "polygon",
586            "--tags",
587            "personal,defi",
588        ])
589        .unwrap();
590
591        if let AddressBookCommands::Add(add_args) = cli.args.command {
592            assert_eq!(
593                add_args.address,
594                "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
595            );
596            assert_eq!(add_args.label, Some("My Wallet".to_string()));
597            assert_eq!(add_args.chain, "polygon");
598            assert_eq!(add_args.tags, vec!["personal", "defi"]);
599        } else {
600            panic!("Expected Add command");
601        }
602    }
603
604    // ========================================================================
605    // Native symbol tests
606    // ========================================================================
607
608    #[test]
609    fn test_get_native_symbol_solana() {
610        assert_eq!(native_symbol("solana"), "SOL");
611        assert_eq!(native_symbol("sol"), "SOL");
612    }
613
614    #[test]
615    fn test_get_native_symbol_ethereum() {
616        assert_eq!(native_symbol("ethereum"), "ETH");
617        assert_eq!(native_symbol("eth"), "ETH");
618    }
619
620    #[test]
621    fn test_get_native_symbol_tron() {
622        assert_eq!(native_symbol("tron"), "TRX");
623        assert_eq!(native_symbol("trx"), "TRX");
624    }
625
626    #[test]
627    fn test_get_native_symbol_unknown() {
628        assert_eq!(native_symbol("bitcoin"), "???");
629        assert_eq!(native_symbol("unknown"), "???");
630    }
631
632    // ========================================================================
633    // End-to-end tests using MockClientFactory
634    // ========================================================================
635
636    use scope::chains::mocks::{MockClientFactory, MockDexSource};
637    use scope::chains::{ChainClient, ChainClientFactory, DexDataSource};
638
639    fn mock_factory() -> MockClientFactory {
640        MockClientFactory::new()
641    }
642
643    use scope::error::ScopeError;
644
645    /// Factory that fails to create chain clients - used to test error paths in fetch_address_balance.
646    struct FailingChainClientFactory;
647
648    impl ChainClientFactory for FailingChainClientFactory {
649        fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
650            scope::http::test_transport()
651        }
652        fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>> {
653            Err(ScopeError::Chain(format!("unsupported chain: {}", chain)))
654        }
655
656        fn create_dex_client(&self) -> Box<dyn DexDataSource> {
657            Box::new(MockDexSource::new())
658        }
659    }
660
661    #[tokio::test]
662    async fn test_run_address_book_list_empty() {
663        let tmp_dir = tempfile::tempdir().unwrap();
664        let config = Config {
665            address_book: scope::config::AddressBookConfig {
666                data_dir: Some(tmp_dir.path().to_path_buf()),
667            },
668            ..Default::default()
669        };
670        let factory = mock_factory();
671        let args = AddressBookArgs {
672            command: AddressBookCommands::List,
673            format: Some(OutputFormat::Table),
674        };
675        let result = super::run(args, &config, &factory, &quiet()).await;
676        assert!(result.is_ok());
677    }
678
679    #[tokio::test]
680    async fn test_run_address_book_add_and_list() {
681        let tmp_dir = tempfile::tempdir().unwrap();
682        let config = Config {
683            address_book: scope::config::AddressBookConfig {
684                data_dir: Some(tmp_dir.path().to_path_buf()),
685            },
686            ..Default::default()
687        };
688        let factory = mock_factory();
689
690        // Add address
691        let add_args = AddressBookArgs {
692            command: AddressBookCommands::Add(AddArgs {
693                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
694                label: Some("Test Wallet".to_string()),
695                chain: "ethereum".to_string(),
696                tags: vec!["test".to_string()],
697            }),
698            format: Some(OutputFormat::Table),
699        };
700        let result = super::run(add_args, &config, &factory, &quiet()).await;
701        assert!(result.is_ok());
702
703        // List
704        let list_args = AddressBookArgs {
705            command: AddressBookCommands::List,
706            format: Some(OutputFormat::Json),
707        };
708        let result = super::run(list_args, &config, &factory, &quiet()).await;
709        assert!(result.is_ok());
710    }
711
712    #[tokio::test]
713    async fn test_run_address_book_summary_with_mock() {
714        let tmp_dir = tempfile::tempdir().unwrap();
715        let config = Config {
716            address_book: scope::config::AddressBookConfig {
717                data_dir: Some(tmp_dir.path().to_path_buf()),
718            },
719            ..Default::default()
720        };
721        let factory = mock_factory();
722
723        // Add address first
724        let add_args = AddressBookArgs {
725            command: AddressBookCommands::Add(AddArgs {
726                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
727                label: Some("Test".to_string()),
728                chain: "ethereum".to_string(),
729                tags: vec![],
730            }),
731            format: None,
732        };
733        super::run(add_args, &config, &factory, &quiet())
734            .await
735            .unwrap();
736
737        // Summary
738        let summary_args = AddressBookArgs {
739            command: AddressBookCommands::Summary(SummaryArgs {
740                chain: None,
741                tag: None,
742                include_tokens: false,
743                report: None,
744            }),
745            format: Some(OutputFormat::Json),
746        };
747        let result = super::run(summary_args, &config, &factory, &quiet()).await;
748        assert!(result.is_ok());
749    }
750
751    #[tokio::test]
752    async fn test_run_address_book_remove() {
753        let tmp_dir = tempfile::tempdir().unwrap();
754        let config = Config {
755            address_book: scope::config::AddressBookConfig {
756                data_dir: Some(tmp_dir.path().to_path_buf()),
757            },
758            ..Default::default()
759        };
760        let factory = mock_factory();
761
762        // Add then remove
763        let add_args = AddressBookArgs {
764            command: AddressBookCommands::Add(AddArgs {
765                address: "0xtest".to_string(),
766                label: None,
767                chain: "ethereum".to_string(),
768                tags: vec![],
769            }),
770            format: None,
771        };
772        super::run(add_args, &config, &factory, &quiet())
773            .await
774            .unwrap();
775
776        let remove_args = AddressBookArgs {
777            command: AddressBookCommands::Remove(RemoveArgs {
778                address: "0xtest".to_string(),
779            }),
780            format: None,
781        };
782        let result = super::run(remove_args, &config, &factory, &quiet()).await;
783        assert!(result.is_ok());
784    }
785
786    #[tokio::test]
787    async fn test_run_address_book_summary_csv() {
788        let tmp_dir = tempfile::tempdir().unwrap();
789        let config = Config {
790            address_book: scope::config::AddressBookConfig {
791                data_dir: Some(tmp_dir.path().to_path_buf()),
792            },
793            ..Default::default()
794        };
795        let factory = mock_factory();
796
797        // Add address
798        let add_args = AddressBookArgs {
799            command: AddressBookCommands::Add(AddArgs {
800                address: "0xtest".to_string(),
801                label: Some("TestAddr".to_string()),
802                chain: "ethereum".to_string(),
803                tags: vec!["defi".to_string()],
804            }),
805            format: None,
806        };
807        super::run(add_args, &config, &factory, &quiet())
808            .await
809            .unwrap();
810
811        // CSV summary
812        let summary_args = AddressBookArgs {
813            command: AddressBookCommands::Summary(SummaryArgs {
814                chain: None,
815                tag: None,
816                include_tokens: false,
817                report: None,
818            }),
819            format: Some(OutputFormat::Csv),
820        };
821        let result = super::run(summary_args, &config, &factory, &quiet()).await;
822        assert!(result.is_ok());
823    }
824
825    #[tokio::test]
826    async fn test_run_address_book_summary_table() {
827        let tmp_dir = tempfile::tempdir().unwrap();
828        let config = Config {
829            address_book: scope::config::AddressBookConfig {
830                data_dir: Some(tmp_dir.path().to_path_buf()),
831            },
832            ..Default::default()
833        };
834        let factory = mock_factory();
835
836        // Add address
837        let add_args = AddressBookArgs {
838            command: AddressBookCommands::Add(AddArgs {
839                address: "0xtest".to_string(),
840                label: Some("TestAddr".to_string()),
841                chain: "ethereum".to_string(),
842                tags: vec![],
843            }),
844            format: None,
845        };
846        super::run(add_args, &config, &factory, &quiet())
847            .await
848            .unwrap();
849
850        // Table summary
851        let summary_args = AddressBookArgs {
852            command: AddressBookCommands::Summary(SummaryArgs {
853                chain: None,
854                tag: None,
855                include_tokens: true,
856                report: None,
857            }),
858            format: Some(OutputFormat::Table),
859        };
860        let result = super::run(summary_args, &config, &factory, &quiet()).await;
861        assert!(result.is_ok());
862    }
863
864    #[tokio::test]
865    async fn test_run_address_book_summary_with_chain_filter() {
866        let tmp_dir = tempfile::tempdir().unwrap();
867        let config = Config {
868            address_book: scope::config::AddressBookConfig {
869                data_dir: Some(tmp_dir.path().to_path_buf()),
870            },
871            ..Default::default()
872        };
873        let factory = mock_factory();
874
875        // Add addresses on different chains
876        let add_eth = AddressBookArgs {
877            command: AddressBookCommands::Add(AddArgs {
878                address: "0xeth".to_string(),
879                label: None,
880                chain: "ethereum".to_string(),
881                tags: vec![],
882            }),
883            format: None,
884        };
885        super::run(add_eth, &config, &factory, &quiet())
886            .await
887            .unwrap();
888
889        let add_poly = AddressBookArgs {
890            command: AddressBookCommands::Add(AddArgs {
891                address: "0xpoly".to_string(),
892                label: None,
893                chain: "polygon".to_string(),
894                tags: vec![],
895            }),
896            format: None,
897        };
898        super::run(add_poly, &config, &factory, &quiet())
899            .await
900            .unwrap();
901
902        // Filter by chain
903        let summary_args = AddressBookArgs {
904            command: AddressBookCommands::Summary(SummaryArgs {
905                chain: Some("ethereum".to_string()),
906                tag: None,
907                include_tokens: false,
908                report: None,
909            }),
910            format: Some(OutputFormat::Json),
911        };
912        let result = super::run(summary_args, &config, &factory, &quiet()).await;
913        assert!(result.is_ok());
914    }
915
916    #[tokio::test]
917    async fn test_run_address_book_summary_with_tag_filter() {
918        let tmp_dir = tempfile::tempdir().unwrap();
919        let config = Config {
920            address_book: scope::config::AddressBookConfig {
921                data_dir: Some(tmp_dir.path().to_path_buf()),
922            },
923            ..Default::default()
924        };
925        let factory = mock_factory();
926
927        // Add addresses with tags
928        let add_args = AddressBookArgs {
929            command: AddressBookCommands::Add(AddArgs {
930                address: "0xdefi".to_string(),
931                label: None,
932                chain: "ethereum".to_string(),
933                tags: vec!["defi".to_string()],
934            }),
935            format: None,
936        };
937        super::run(add_args, &config, &factory, &quiet())
938            .await
939            .unwrap();
940
941        // Filter by tag
942        let summary_args = AddressBookArgs {
943            command: AddressBookCommands::Summary(SummaryArgs {
944                chain: None,
945                tag: Some("defi".to_string()),
946                include_tokens: false,
947                report: None,
948            }),
949            format: Some(OutputFormat::Json),
950        };
951        let result = super::run(summary_args, &config, &factory, &quiet()).await;
952        assert!(result.is_ok());
953    }
954
955    #[tokio::test]
956    async fn test_run_address_book_summary_no_format() {
957        let tmp_dir = tempfile::tempdir().unwrap();
958        let config = Config {
959            address_book: scope::config::AddressBookConfig {
960                data_dir: Some(tmp_dir.path().to_path_buf()),
961            },
962            ..Default::default()
963        };
964        let factory = mock_factory();
965
966        let add_args = AddressBookArgs {
967            command: AddressBookCommands::Add(AddArgs {
968                address: "0xtest".to_string(),
969                label: None,
970                chain: "ethereum".to_string(),
971                tags: vec![],
972            }),
973            format: None,
974        };
975        super::run(add_args, &config, &factory, &quiet())
976            .await
977            .unwrap();
978
979        let summary_args = AddressBookArgs {
980            command: AddressBookCommands::Summary(SummaryArgs {
981                chain: None,
982                tag: None,
983                include_tokens: false,
984                report: None,
985            }),
986            format: None, // Default format
987        };
988        let result = super::run(summary_args, &config, &factory, &quiet()).await;
989        assert!(result.is_ok());
990    }
991
992    #[tokio::test]
993    async fn test_run_address_book_summary_empty() {
994        let tmp_dir = tempfile::tempdir().unwrap();
995        let config = Config {
996            address_book: scope::config::AddressBookConfig {
997                data_dir: Some(tmp_dir.path().to_path_buf()),
998            },
999            ..Default::default()
1000        };
1001        let factory = mock_factory();
1002
1003        // Summary with no addresses added
1004        let summary_args = AddressBookArgs {
1005            command: AddressBookCommands::Summary(SummaryArgs {
1006                chain: None,
1007                tag: None,
1008                include_tokens: false,
1009                report: None,
1010            }),
1011            format: Some(OutputFormat::Table),
1012        };
1013        let result = super::run(summary_args, &config, &factory, &quiet()).await;
1014        assert!(result.is_ok());
1015    }
1016
1017    #[tokio::test]
1018    async fn test_run_address_book_add_with_tags() {
1019        let tmp_dir = tempfile::tempdir().unwrap();
1020        let config = Config {
1021            address_book: scope::config::AddressBookConfig {
1022                data_dir: Some(tmp_dir.path().to_path_buf()),
1023            },
1024            ..Default::default()
1025        };
1026        let factory = mock_factory();
1027
1028        let add_args = AddressBookArgs {
1029            command: AddressBookCommands::Add(AddArgs {
1030                address: "0xtagged".to_string(),
1031                label: Some("Tagged".to_string()),
1032                chain: "ethereum".to_string(),
1033                tags: vec!["defi".to_string(), "whale".to_string()],
1034            }),
1035            format: None,
1036        };
1037        let result = super::run(add_args, &config, &factory, &quiet()).await;
1038        assert!(result.is_ok());
1039    }
1040
1041    #[test]
1042    fn test_get_native_symbol_polygon() {
1043        assert_eq!(native_symbol("polygon"), "MATIC");
1044    }
1045
1046    #[test]
1047    fn test_get_native_symbol_bsc() {
1048        assert_eq!(native_symbol("bsc"), "BNB");
1049    }
1050
1051    #[test]
1052    fn test_get_native_symbol_evm_l2s() {
1053        assert_eq!(native_symbol("arbitrum"), "ETH");
1054        assert_eq!(native_symbol("optimism"), "ETH");
1055        assert_eq!(native_symbol("base"), "ETH");
1056    }
1057
1058    #[tokio::test]
1059    async fn test_run_address_book_list_csv_format() {
1060        let tmp_dir = tempfile::tempdir().unwrap();
1061        let config = Config {
1062            address_book: scope::config::AddressBookConfig {
1063                data_dir: Some(tmp_dir.path().to_path_buf()),
1064            },
1065            ..Default::default()
1066        };
1067        let factory = mock_factory();
1068
1069        // Add address
1070        let add_args = AddressBookArgs {
1071            command: AddressBookCommands::Add(AddArgs {
1072                address: "0xCSV_test".to_string(),
1073                label: Some("CsvAddr".to_string()),
1074                chain: "ethereum".to_string(),
1075                tags: vec!["test".to_string()],
1076            }),
1077            format: None,
1078        };
1079        super::run(add_args, &config, &factory, &quiet())
1080            .await
1081            .unwrap();
1082
1083        // List with CSV
1084        let list_args = AddressBookArgs {
1085            command: AddressBookCommands::List,
1086            format: Some(OutputFormat::Csv),
1087        };
1088        let result = super::run(list_args, &config, &factory, &quiet()).await;
1089        assert!(result.is_ok());
1090    }
1091
1092    #[tokio::test]
1093    async fn test_run_address_book_list_table_format() {
1094        let tmp_dir = tempfile::tempdir().unwrap();
1095        let config = Config {
1096            address_book: scope::config::AddressBookConfig {
1097                data_dir: Some(tmp_dir.path().to_path_buf()),
1098            },
1099            ..Default::default()
1100        };
1101        let factory = mock_factory();
1102
1103        // Add addresses with and without labels
1104        let add_args = AddressBookArgs {
1105            command: AddressBookCommands::Add(AddArgs {
1106                address: "0xTable_test1".to_string(),
1107                label: Some("LabeledAddr".to_string()),
1108                chain: "ethereum".to_string(),
1109                tags: vec!["personal".to_string(), "defi".to_string()],
1110            }),
1111            format: None,
1112        };
1113        super::run(add_args, &config, &factory, &quiet())
1114            .await
1115            .unwrap();
1116
1117        let add_args2 = AddressBookArgs {
1118            command: AddressBookCommands::Add(AddArgs {
1119                address: "0xTable_test2".to_string(),
1120                label: None,
1121                chain: "polygon".to_string(),
1122                tags: vec![],
1123            }),
1124            format: None,
1125        };
1126        super::run(add_args2, &config, &factory, &quiet())
1127            .await
1128            .unwrap();
1129
1130        // List with Table
1131        let list_args = AddressBookArgs {
1132            command: AddressBookCommands::List,
1133            format: Some(OutputFormat::Table),
1134        };
1135        let result = super::run(list_args, &config, &factory, &quiet()).await;
1136        assert!(result.is_ok());
1137    }
1138
1139    #[tokio::test]
1140    async fn test_run_address_book_summary_table_with_tokens() {
1141        let tmp_dir = tempfile::tempdir().unwrap();
1142        let config = Config {
1143            address_book: scope::config::AddressBookConfig {
1144                data_dir: Some(tmp_dir.path().to_path_buf()),
1145            },
1146            ..Default::default()
1147        };
1148        let factory = mock_factory();
1149
1150        // Add address
1151        let add_args = AddressBookArgs {
1152            command: AddressBookCommands::Add(AddArgs {
1153                address: "0xTokenTest".to_string(),
1154                label: Some("TokenAddr".to_string()),
1155                chain: "ethereum".to_string(),
1156                tags: vec![],
1157            }),
1158            format: None,
1159        };
1160        super::run(add_args, &config, &factory, &quiet())
1161            .await
1162            .unwrap();
1163
1164        // Summary with Table and tokens included
1165        let summary_args = AddressBookArgs {
1166            command: AddressBookCommands::Summary(SummaryArgs {
1167                chain: None,
1168                tag: None,
1169                include_tokens: true,
1170                report: None,
1171            }),
1172            format: Some(OutputFormat::Table),
1173        };
1174        let result = super::run(summary_args, &config, &factory, &quiet()).await;
1175        assert!(result.is_ok());
1176    }
1177
1178    #[tokio::test]
1179    async fn test_run_address_book_summary_multiple_chains() {
1180        let tmp_dir = tempfile::tempdir().unwrap();
1181        let config = Config {
1182            address_book: scope::config::AddressBookConfig {
1183                data_dir: Some(tmp_dir.path().to_path_buf()),
1184            },
1185            ..Default::default()
1186        };
1187        let factory = mock_factory();
1188
1189        // Add addresses on the same chain to test chain balance aggregation
1190        let add1 = AddressBookArgs {
1191            command: AddressBookCommands::Add(AddArgs {
1192                address: "0xMulti1".to_string(),
1193                label: None,
1194                chain: "ethereum".to_string(),
1195                tags: vec![],
1196            }),
1197            format: None,
1198        };
1199        super::run(add1, &config, &factory, &quiet()).await.unwrap();
1200
1201        let add2 = AddressBookArgs {
1202            command: AddressBookCommands::Add(AddArgs {
1203                address: "0xMulti2".to_string(),
1204                label: None,
1205                chain: "ethereum".to_string(),
1206                tags: vec![],
1207            }),
1208            format: None,
1209        };
1210        super::run(add2, &config, &factory, &quiet()).await.unwrap();
1211
1212        // Summary - should aggregate chain balances
1213        let summary_args = AddressBookArgs {
1214            command: AddressBookCommands::Summary(SummaryArgs {
1215                chain: None,
1216                tag: None,
1217                include_tokens: false,
1218                report: None,
1219            }),
1220            format: Some(OutputFormat::Table),
1221        };
1222        let result = super::run(summary_args, &config, &factory, &quiet()).await;
1223        assert!(result.is_ok());
1224    }
1225
1226    #[tokio::test]
1227    async fn test_run_address_book_list_no_format() {
1228        let tmp_dir = tempfile::tempdir().unwrap();
1229        let config = Config {
1230            address_book: scope::config::AddressBookConfig {
1231                data_dir: Some(tmp_dir.path().to_path_buf()),
1232            },
1233            ..Default::default()
1234        };
1235        let factory = mock_factory();
1236
1237        // Add address
1238        let add_args = AddressBookArgs {
1239            command: AddressBookCommands::Add(AddArgs {
1240                address: "0xNoFmt".to_string(),
1241                label: Some("Test".to_string()),
1242                chain: "ethereum".to_string(),
1243                tags: vec![],
1244            }),
1245            format: None,
1246        };
1247        super::run(add_args, &config, &factory, &quiet())
1248            .await
1249            .unwrap();
1250
1251        // List with default format (None -> Table)
1252        let list_args = AddressBookArgs {
1253            command: AddressBookCommands::List,
1254            format: None,
1255        };
1256        let result = super::run(list_args, &config, &factory, &quiet()).await;
1257        assert!(result.is_ok());
1258    }
1259
1260    #[test]
1261    fn test_address_book_new() {
1262        let p = AddressBook::default();
1263        assert!(p.addresses.is_empty());
1264    }
1265
1266    #[test]
1267    fn test_address_book_load_missing_dir() {
1268        let temp = tempfile::tempdir().unwrap();
1269        let p = AddressBook::load(temp.path());
1270        assert!(p.is_ok());
1271        assert!(p.unwrap().addresses.is_empty());
1272    }
1273
1274    #[test]
1275    fn test_address_book_add_and_save_roundtrip() {
1276        let temp = tempfile::tempdir().unwrap();
1277        let mut p = AddressBook::default();
1278        let addr = WatchedAddress {
1279            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1280            label: Some("Test".to_string()),
1281            chain: "ethereum".to_string(),
1282            tags: vec!["tag1".to_string()],
1283            added_at: 1234567890,
1284        };
1285        p.add_address(addr).unwrap();
1286        assert_eq!(p.addresses.len(), 1);
1287
1288        let data_dir = temp.path().to_path_buf();
1289        p.save(&data_dir).unwrap();
1290        let loaded = AddressBook::load(temp.path()).unwrap();
1291        assert_eq!(loaded.addresses.len(), 1);
1292        assert_eq!(
1293            loaded.addresses[0].address,
1294            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1295        );
1296        assert_eq!(loaded.addresses[0].label, Some("Test".to_string()));
1297    }
1298
1299    #[test]
1300    fn test_address_book_add_duplicate() {
1301        let mut p = AddressBook::default();
1302        let addr1 = WatchedAddress {
1303            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1304            label: None,
1305            chain: "ethereum".to_string(),
1306            tags: vec![],
1307            added_at: 0,
1308        };
1309        let addr2 = WatchedAddress {
1310            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1311            label: None,
1312            chain: "ethereum".to_string(),
1313            tags: vec![],
1314            added_at: 0,
1315        };
1316        p.add_address(addr1).unwrap();
1317        let result = p.add_address(addr2);
1318        // Should error on duplicate
1319        assert!(result.is_err());
1320        assert!(
1321            result
1322                .unwrap_err()
1323                .to_string()
1324                .contains("already in address book")
1325        );
1326    }
1327
1328    #[test]
1329    fn test_watched_address_debug() {
1330        let addr = WatchedAddress {
1331            address: "0xtest".to_string(),
1332            label: Some("My Wallet".to_string()),
1333            chain: "ethereum".to_string(),
1334            tags: vec!["defi".to_string(), "staking".to_string()],
1335            added_at: 1700000000,
1336        };
1337        let debug = format!("{:?}", addr);
1338        assert!(debug.contains("WatchedAddress"));
1339        assert!(debug.contains("0xtest"));
1340    }
1341
1342    // ========================================================================
1343    // address_book_summary_to_markdown tests
1344    // ========================================================================
1345
1346    #[test]
1347    fn test_address_book_summary_to_markdown_basic() {
1348        let mut balances_by_chain = HashMap::new();
1349        balances_by_chain.insert(
1350            "ethereum".to_string(),
1351            ChainBalance {
1352                native_balance: "1.5".to_string(),
1353                symbol: "ETH".to_string(),
1354                usd: None,
1355            },
1356        );
1357
1358        let summary = AddressBookSummary {
1359            address_count: 2,
1360            balances_by_chain,
1361            total_usd: None,
1362            addresses: vec![
1363                AddressSummary {
1364                    address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1365                    label: Some("Main Wallet".to_string()),
1366                    chain: "ethereum".to_string(),
1367                    balance: "1.5".to_string(),
1368                    usd: None,
1369                    tokens: vec![],
1370                },
1371                AddressSummary {
1372                    address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
1373                    label: None,
1374                    chain: "polygon".to_string(),
1375                    balance: "100.0".to_string(),
1376                    usd: None,
1377                    tokens: vec![],
1378                },
1379            ],
1380        };
1381
1382        let md = address_book_summary_to_markdown(&summary);
1383
1384        // Check header elements
1385        assert!(md.contains("# Address Book Report"));
1386        assert!(md.contains("**Addresses:** 2"));
1387        assert!(md.contains("Allocation by Chain"));
1388        assert!(md.contains("## Addresses"));
1389
1390        // Check chain balance table
1391        assert!(md.contains("ethereum"));
1392        assert!(md.contains("1.5"));
1393        assert!(md.contains("ETH"));
1394
1395        // Check address table
1396        assert!(md.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
1397        assert!(md.contains("Main Wallet"));
1398        assert!(md.contains("0xABCdef1234567890abcdef1234567890ABCDEF12"));
1399        assert!(md.contains("polygon"));
1400        assert!(md.contains("100.0"));
1401
1402        // Check footer
1403        assert!(md.contains("Report generated by Scope"));
1404    }
1405
1406    #[test]
1407    fn test_address_book_summary_to_markdown_with_usd() {
1408        let mut balances_by_chain = HashMap::new();
1409        balances_by_chain.insert(
1410            "ethereum".to_string(),
1411            ChainBalance {
1412                native_balance: "2.0".to_string(),
1413                symbol: "ETH".to_string(),
1414                usd: Some(3000.0),
1415            },
1416        );
1417
1418        let summary = AddressBookSummary {
1419            address_count: 2,
1420            balances_by_chain,
1421            total_usd: Some(5000.0),
1422            addresses: vec![
1423                AddressSummary {
1424                    address: "0x1234567890123456789012345678901234567890".to_string(),
1425                    label: Some("Wallet 1".to_string()),
1426                    chain: "ethereum".to_string(),
1427                    balance: "2.0".to_string(),
1428                    usd: Some(3000.0),
1429                    tokens: vec![],
1430                },
1431                AddressSummary {
1432                    address: "0x0987654321098765432109876543210987654321".to_string(),
1433                    label: Some("Wallet 2".to_string()),
1434                    chain: "ethereum".to_string(),
1435                    balance: "1.0".to_string(),
1436                    usd: Some(2000.0),
1437                    tokens: vec![],
1438                },
1439            ],
1440        };
1441
1442        let md = address_book_summary_to_markdown(&summary);
1443
1444        // Check total USD
1445        assert!(md.contains("**Total Value (USD):** $5000.00"));
1446
1447        // Check chain USD value
1448        assert!(md.contains("$3000.00"));
1449
1450        // Check address USD values
1451        assert!(md.contains("$3000.00"));
1452        assert!(md.contains("$2000.00"));
1453    }
1454
1455    #[test]
1456    fn test_address_book_summary_to_markdown_with_tokens() {
1457        let mut balances_by_chain = HashMap::new();
1458        balances_by_chain.insert(
1459            "ethereum".to_string(),
1460            ChainBalance {
1461                native_balance: "1.0".to_string(),
1462                symbol: "ETH".to_string(),
1463                usd: None,
1464            },
1465        );
1466
1467        // Create more than 3 tokens to test truncation
1468        let tokens = vec![
1469            TokenSummary {
1470                contract_address: "0xToken1".to_string(),
1471                balance: "100.0".to_string(),
1472                decimals: 18,
1473                symbol: Some("USDC".to_string()),
1474            },
1475            TokenSummary {
1476                contract_address: "0xToken2".to_string(),
1477                balance: "50.0".to_string(),
1478                decimals: 18,
1479                symbol: Some("DAI".to_string()),
1480            },
1481            TokenSummary {
1482                contract_address: "0xToken3".to_string(),
1483                balance: "25.0".to_string(),
1484                decimals: 18,
1485                symbol: Some("WBTC".to_string()),
1486            },
1487            TokenSummary {
1488                contract_address: "0xToken4".to_string(),
1489                balance: "10.0".to_string(),
1490                decimals: 18,
1491                symbol: Some("UNI".to_string()),
1492            },
1493            TokenSummary {
1494                contract_address: "0xToken5".to_string(),
1495                balance: "5.0".to_string(),
1496                decimals: 18,
1497                symbol: None, // Test token without symbol
1498            },
1499        ];
1500
1501        let summary = AddressBookSummary {
1502            address_count: 1,
1503            balances_by_chain,
1504            total_usd: None,
1505            addresses: vec![AddressSummary {
1506                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1507                label: Some("Token Wallet".to_string()),
1508                chain: "ethereum".to_string(),
1509                balance: "1.0".to_string(),
1510                usd: None,
1511                tokens,
1512            }],
1513        };
1514
1515        let md = address_book_summary_to_markdown(&summary);
1516
1517        // Check that first 3 tokens are shown
1518        assert!(md.contains("USDC"));
1519        assert!(md.contains("DAI"));
1520        assert!(md.contains("WBTC"));
1521
1522        // Check truncation indicator (+2 for 5 tokens - 3 shown)
1523        assert!(md.contains("+2"));
1524
1525        // Check that token without symbol uses contract address
1526        // The first 3 tokens have symbols, so we should see USDC, DAI, WBTC
1527        // Token 4 (UNI) and Token 5 (no symbol) should be truncated
1528        // But we need to verify the truncation logic shows "+2"
1529    }
1530
1531    #[test]
1532    fn test_address_book_summary_to_markdown_empty() {
1533        let summary = AddressBookSummary {
1534            address_count: 0,
1535            balances_by_chain: HashMap::new(),
1536            total_usd: None,
1537            addresses: vec![],
1538        };
1539
1540        let md = address_book_summary_to_markdown(&summary);
1541
1542        // Check header
1543        assert!(md.contains("# Address Book Report"));
1544        assert!(md.contains("**Addresses:** 0"));
1545
1546        // Check that chain allocation section exists (even if empty)
1547        assert!(md.contains("Allocation by Chain"));
1548
1549        // Check that addresses section exists (even if empty)
1550        assert!(md.contains("## Addresses"));
1551
1552        // Check footer
1553        assert!(md.contains("Report generated by Scope"));
1554    }
1555
1556    // ========================================================================
1557    // find_by_label tests
1558    // ========================================================================
1559
1560    #[test]
1561    fn test_find_by_label_exact_match() {
1562        let address_book = create_test_address_book();
1563        let found = address_book.find_by_label("Main Wallet");
1564        assert!(found.is_some());
1565        assert_eq!(
1566            found.unwrap().address,
1567            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1568        );
1569    }
1570
1571    #[test]
1572    fn test_find_by_label_case_insensitive() {
1573        let address_book = create_test_address_book();
1574        let found = address_book.find_by_label("main wallet");
1575        assert!(found.is_some());
1576        assert_eq!(
1577            found.unwrap().address,
1578            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1579        );
1580    }
1581
1582    #[test]
1583    fn test_find_by_label_with_whitespace() {
1584        let address_book = create_test_address_book();
1585        let found = address_book.find_by_label("  Main Wallet  ");
1586        assert!(found.is_some());
1587    }
1588
1589    #[test]
1590    fn test_find_by_label_not_found() {
1591        let address_book = create_test_address_book();
1592        let found = address_book.find_by_label("nonexistent");
1593        assert!(found.is_none());
1594    }
1595
1596    #[test]
1597    fn test_find_by_label_no_label_entries() {
1598        let address_book = create_test_address_book();
1599        // Second entry has no label
1600        let found = address_book.find_by_label("");
1601        assert!(found.is_none());
1602    }
1603
1604    #[test]
1605    fn test_find_by_label_empty_address_book() {
1606        let address_book = AddressBook::default();
1607        let found = address_book.find_by_label("anything");
1608        assert!(found.is_none());
1609    }
1610
1611    // ========================================================================
1612    // resolve_address_book_input tests
1613    // ========================================================================
1614
1615    #[test]
1616    fn test_resolve_address_book_input_by_label() {
1617        let tmp_dir = TempDir::new().unwrap();
1618        let mut address_book = AddressBook::default();
1619        address_book
1620            .add_address(WatchedAddress {
1621                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1622                label: Some("hot-wallet".to_string()),
1623                chain: "ethereum".to_string(),
1624                tags: vec![],
1625                added_at: 0,
1626            })
1627            .unwrap();
1628        address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
1629
1630        let config = Config {
1631            address_book: scope::config::AddressBookConfig {
1632                data_dir: Some(tmp_dir.path().to_path_buf()),
1633            },
1634            ..Default::default()
1635        };
1636
1637        let result = resolve_address_book_input("@hot-wallet", &config).unwrap();
1638        assert!(result.is_some());
1639        let (addr, chain) = result.unwrap();
1640        assert_eq!(addr, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
1641        assert_eq!(chain, "ethereum");
1642    }
1643
1644    #[test]
1645    fn test_resolve_address_book_input_by_address() {
1646        let tmp_dir = TempDir::new().unwrap();
1647        let mut address_book = AddressBook::default();
1648        address_book
1649            .add_address(WatchedAddress {
1650                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1651                label: Some("test".to_string()),
1652                chain: "polygon".to_string(),
1653                tags: vec![],
1654                added_at: 0,
1655            })
1656            .unwrap();
1657        address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
1658
1659        let config = Config {
1660            address_book: scope::config::AddressBookConfig {
1661                data_dir: Some(tmp_dir.path().to_path_buf()),
1662            },
1663            ..Default::default()
1664        };
1665
1666        // Resolve by raw address — should still match and return chain info
1667        let result =
1668            resolve_address_book_input("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", &config)
1669                .unwrap();
1670        assert!(result.is_some());
1671        let (_addr, chain) = result.unwrap();
1672        assert_eq!(chain, "polygon");
1673    }
1674
1675    #[test]
1676    fn test_resolve_address_book_input_not_found() {
1677        let tmp_dir = TempDir::new().unwrap();
1678        let config = Config {
1679            address_book: scope::config::AddressBookConfig {
1680                data_dir: Some(tmp_dir.path().to_path_buf()),
1681            },
1682            ..Default::default()
1683        };
1684
1685        let result = resolve_address_book_input("@unknown-label", &config);
1686        assert!(result.is_err());
1687    }
1688
1689    #[test]
1690    fn test_resolve_address_book_input_empty_address_book() {
1691        let tmp_dir = TempDir::new().unwrap();
1692        let config = Config {
1693            address_book: scope::config::AddressBookConfig {
1694                data_dir: Some(tmp_dir.path().to_path_buf()),
1695            },
1696            ..Default::default()
1697        };
1698
1699        let result = resolve_address_book_input("@anything", &config);
1700        assert!(result.is_err());
1701    }
1702
1703    #[test]
1704    fn test_resolve_address_book_input_label_not_found_with_available_labels() {
1705        let tmp_dir = TempDir::new().unwrap();
1706        let mut address_book = AddressBook::default();
1707        address_book
1708            .add_address(WatchedAddress {
1709                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1710                label: Some("main-wallet".to_string()),
1711                chain: "ethereum".to_string(),
1712                tags: vec![],
1713                added_at: 0,
1714            })
1715            .unwrap();
1716        address_book
1717            .add_address(WatchedAddress {
1718                address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
1719                label: Some("trading".to_string()),
1720                chain: "polygon".to_string(),
1721                tags: vec![],
1722                added_at: 0,
1723            })
1724            .unwrap();
1725        address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
1726
1727        let config = Config {
1728            address_book: scope::config::AddressBookConfig {
1729                data_dir: Some(tmp_dir.path().to_path_buf()),
1730            },
1731            ..Default::default()
1732        };
1733
1734        let result = resolve_address_book_input("@nonexistent-label", &config);
1735        assert!(result.is_err());
1736        let err_msg = result.unwrap_err().to_string();
1737        assert!(err_msg.contains("No address book entry matching '@nonexistent-label'"));
1738        assert!(err_msg.contains("Available labels"));
1739        assert!(err_msg.contains("@main-wallet"));
1740        assert!(err_msg.contains("@trading"));
1741    }
1742
1743    #[test]
1744    fn test_resolve_address_book_input_case_insensitive_label() {
1745        let tmp_dir = TempDir::new().unwrap();
1746        let mut address_book = AddressBook::default();
1747        address_book
1748            .add_address(WatchedAddress {
1749                address: "0xABCDEF1234567890abcdef1234567890ABCDEF12".to_string(),
1750                label: Some("My DeFi Wallet".to_string()),
1751                chain: "arbitrum".to_string(),
1752                tags: vec![],
1753                added_at: 0,
1754            })
1755            .unwrap();
1756        address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
1757
1758        let config = Config {
1759            address_book: scope::config::AddressBookConfig {
1760                data_dir: Some(tmp_dir.path().to_path_buf()),
1761            },
1762            ..Default::default()
1763        };
1764
1765        let result = resolve_address_book_input("@my defi wallet", &config).unwrap();
1766        assert!(result.is_some());
1767        let (addr, chain) = result.unwrap();
1768        assert_eq!(addr, "0xABCDEF1234567890abcdef1234567890ABCDEF12");
1769        assert_eq!(chain, "arbitrum");
1770    }
1771
1772    #[test]
1773    fn test_resolve_address_book_input_raw_address_not_in_book_returns_none() {
1774        let tmp_dir = TempDir::new().unwrap();
1775        let mut address_book = AddressBook::default();
1776        address_book
1777            .add_address(WatchedAddress {
1778                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1779                label: Some("test".to_string()),
1780                chain: "ethereum".to_string(),
1781                tags: vec![],
1782                added_at: 0,
1783            })
1784            .unwrap();
1785        address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
1786
1787        let config = Config {
1788            address_book: scope::config::AddressBookConfig {
1789                data_dir: Some(tmp_dir.path().to_path_buf()),
1790            },
1791            ..Default::default()
1792        };
1793
1794        // Raw address not in book (no @ prefix) -> Ok(None)
1795        let result =
1796            resolve_address_book_input("0xnonexistent123456789012345678901234567890", &config)
1797                .unwrap();
1798        assert!(result.is_none());
1799    }
1800
1801    #[test]
1802    fn test_resolve_address_book_input_load_fails_returns_none() {
1803        let tmp_dir = TempDir::new().unwrap();
1804        let address_book_path = tmp_dir.path().join("address_book.yaml");
1805        std::fs::create_dir_all(tmp_dir.path()).unwrap();
1806        std::fs::write(&address_book_path, "invalid: yaml: content: [").unwrap();
1807
1808        let config = Config {
1809            address_book: scope::config::AddressBookConfig {
1810                data_dir: Some(tmp_dir.path().to_path_buf()),
1811            },
1812            ..Default::default()
1813        };
1814
1815        // When load fails (parse error), returns Ok(None)
1816        let result =
1817            resolve_address_book_input("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", &config);
1818        assert!(result.is_ok());
1819        assert!(result.unwrap().is_none());
1820    }
1821
1822    #[test]
1823    fn test_address_book_remove_address_case_insensitive() {
1824        let mut address_book = create_test_address_book();
1825        let original_len = address_book.addresses.len();
1826
1827        let removed = address_book
1828            .remove_address("0x742D35CC6634C0532925A3B844BC9E7595F1B3C2")
1829            .unwrap();
1830
1831        assert!(removed);
1832        assert_eq!(address_book.addresses.len(), original_len - 1);
1833    }
1834
1835    #[test]
1836    fn test_address_book_remove_args_parsing() {
1837        use clap::Parser;
1838
1839        #[derive(Parser)]
1840        struct TestCli {
1841            #[command(flatten)]
1842            args: AddressBookArgs,
1843        }
1844
1845        let cli = TestCli::try_parse_from([
1846            "test",
1847            "remove",
1848            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1849        ])
1850        .unwrap();
1851
1852        if let AddressBookCommands::Remove(remove_args) = cli.args.command {
1853            assert_eq!(
1854                remove_args.address,
1855                "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1856            );
1857        } else {
1858            panic!("Expected Remove command");
1859        }
1860    }
1861
1862    #[test]
1863    fn test_address_book_summary_args_parsing() {
1864        use clap::Parser;
1865
1866        #[derive(Parser)]
1867        struct TestCli {
1868            #[command(flatten)]
1869            args: AddressBookArgs,
1870        }
1871
1872        let cli = TestCli::try_parse_from([
1873            "test",
1874            "summary",
1875            "--chain",
1876            "ethereum",
1877            "--tag",
1878            "defi",
1879            "--include-tokens",
1880            "--report",
1881            "report.md",
1882        ])
1883        .unwrap();
1884
1885        if let AddressBookCommands::Summary(summary_args) = cli.args.command {
1886            assert_eq!(summary_args.chain, Some("ethereum".to_string()));
1887            assert_eq!(summary_args.tag, Some("defi".to_string()));
1888            assert!(summary_args.include_tokens);
1889            assert_eq!(
1890                summary_args.report,
1891                Some(std::path::PathBuf::from("report.md"))
1892            );
1893        } else {
1894            panic!("Expected Summary command");
1895        }
1896    }
1897
1898    #[test]
1899    fn test_token_summary_serialization() {
1900        let token = TokenSummary {
1901            contract_address: "0xToken123".to_string(),
1902            balance: "100.5".to_string(),
1903            decimals: 18,
1904            symbol: Some("USDC".to_string()),
1905        };
1906
1907        let json = serde_json::to_string(&token).unwrap();
1908        assert!(json.contains("0xToken123"));
1909        assert!(json.contains("100.5"));
1910        assert!(json.contains("USDC"));
1911
1912        let token_no_symbol = TokenSummary {
1913            contract_address: "0xNoSymbol".to_string(),
1914            balance: "50.0".to_string(),
1915            decimals: 6,
1916            symbol: None,
1917        };
1918        let json2 = serde_json::to_string(&token_no_symbol).unwrap();
1919        assert!(!json2.contains("symbol"));
1920    }
1921
1922    #[test]
1923    fn test_chain_balance_serialization_without_usd() {
1924        let balance = ChainBalance {
1925            native_balance: "10.5".to_string(),
1926            symbol: "ETH".to_string(),
1927            usd: None,
1928        };
1929
1930        let json = serde_json::to_string(&balance).unwrap();
1931        assert!(json.contains("10.5"));
1932        assert!(json.contains("ETH"));
1933        assert!(!json.contains("usd"));
1934    }
1935
1936    #[test]
1937    fn test_address_book_summary_to_markdown_empty_tokens_display() {
1938        let summary = AddressBookSummary {
1939            address_count: 1,
1940            balances_by_chain: HashMap::new(),
1941            total_usd: None,
1942            addresses: vec![AddressSummary {
1943                address: "0xEmptyTokens".to_string(),
1944                label: None,
1945                chain: "ethereum".to_string(),
1946                balance: "1.0".to_string(),
1947                usd: None,
1948                tokens: vec![],
1949            }],
1950        };
1951
1952        let md = address_book_summary_to_markdown(&summary);
1953        assert!(md.contains("0xEmptyTokens"));
1954        assert!(md.contains("| Address | Label | Chain | Balance | USD | Tokens |"));
1955        assert!(md.contains("-"));
1956    }
1957
1958    #[test]
1959    fn test_address_book_summary_to_markdown_token_without_symbol_uses_contract() {
1960        let mut balances = HashMap::new();
1961        balances.insert(
1962            "ethereum".to_string(),
1963            ChainBalance {
1964                native_balance: "1.0".to_string(),
1965                symbol: "ETH".to_string(),
1966                usd: None,
1967            },
1968        );
1969
1970        let summary = AddressBookSummary {
1971            address_count: 1,
1972            balances_by_chain: balances,
1973            total_usd: None,
1974            addresses: vec![AddressSummary {
1975                address: "0xAddr".to_string(),
1976                label: None,
1977                chain: "ethereum".to_string(),
1978                balance: "1.0".to_string(),
1979                usd: None,
1980                tokens: vec![TokenSummary {
1981                    contract_address: "0xUnknownToken12345678".to_string(),
1982                    balance: "100".to_string(),
1983                    decimals: 18,
1984                    symbol: None,
1985                }],
1986            }],
1987        };
1988
1989        let md = address_book_summary_to_markdown(&summary);
1990        assert!(md.contains("0xUnknownToken12345678"));
1991    }
1992
1993    #[test]
1994    fn test_address_book_load_invalid_yaml_returns_error() {
1995        let temp_dir = tempfile::tempdir().unwrap();
1996        let invalid_path = temp_dir.path().join("address_book.yaml");
1997        std::fs::write(&invalid_path, "not valid: yaml: [unclosed").unwrap();
1998
1999        let result = AddressBook::load(temp_dir.path());
2000        assert!(result.is_err());
2001    }
2002
2003    #[test]
2004    fn test_address_book_save_creates_directory() {
2005        let temp_dir = tempfile::tempdir().unwrap();
2006        let nested_dir = temp_dir.path().join("scope").join("nested");
2007        let address_book = create_test_address_book();
2008
2009        let result = address_book.save(&nested_dir);
2010        assert!(result.is_ok());
2011        assert!(nested_dir.exists());
2012        assert!(nested_dir.join("address_book.yaml").exists());
2013    }
2014
2015    #[tokio::test]
2016    async fn test_run_address_book_add_without_label() {
2017        let tmp_dir = tempfile::tempdir().unwrap();
2018        let config = Config {
2019            address_book: scope::config::AddressBookConfig {
2020                data_dir: Some(tmp_dir.path().to_path_buf()),
2021            },
2022            ..Default::default()
2023        };
2024        let factory = mock_factory();
2025
2026        let add_args = AddressBookArgs {
2027            command: AddressBookCommands::Add(AddArgs {
2028                address: "0xNoLabel".to_string(),
2029                label: None,
2030                chain: "ethereum".to_string(),
2031                tags: vec![],
2032            }),
2033            format: None,
2034        };
2035        let result = super::run(add_args, &config, &factory, &quiet()).await;
2036        assert!(result.is_ok());
2037    }
2038
2039    #[tokio::test]
2040    async fn test_run_address_book_remove_nonexistent_address() {
2041        let tmp_dir = tempfile::tempdir().unwrap();
2042        let config = Config {
2043            address_book: scope::config::AddressBookConfig {
2044                data_dir: Some(tmp_dir.path().to_path_buf()),
2045            },
2046            ..Default::default()
2047        };
2048        let factory = mock_factory();
2049
2050        let remove_args = AddressBookArgs {
2051            command: AddressBookCommands::Remove(RemoveArgs {
2052                address: "0xNeverAdded".to_string(),
2053            }),
2054            format: None,
2055        };
2056        let result = super::run(remove_args, &config, &factory, &quiet()).await;
2057        assert!(result.is_ok());
2058    }
2059
2060    #[tokio::test]
2061    async fn test_run_address_book_list_markdown_format() {
2062        let tmp_dir = tempfile::tempdir().unwrap();
2063        let config = Config {
2064            address_book: scope::config::AddressBookConfig {
2065                data_dir: Some(tmp_dir.path().to_path_buf()),
2066            },
2067            ..Default::default()
2068        };
2069        let factory = mock_factory();
2070
2071        let add_args = AddressBookArgs {
2072            command: AddressBookCommands::Add(AddArgs {
2073                address: "0xMdTest".to_string(),
2074                label: Some("MarkdownAddr".to_string()),
2075                chain: "ethereum".to_string(),
2076                tags: vec!["test".to_string()],
2077            }),
2078            format: None,
2079        };
2080        super::run(add_args, &config, &factory, &quiet())
2081            .await
2082            .unwrap();
2083
2084        let list_args = AddressBookArgs {
2085            command: AddressBookCommands::List,
2086            format: Some(OutputFormat::Markdown),
2087        };
2088        let result = super::run(list_args, &config, &factory, &quiet()).await;
2089        assert!(result.is_ok());
2090    }
2091
2092    #[tokio::test]
2093    async fn test_run_address_book_summary_markdown_format() {
2094        let tmp_dir = tempfile::tempdir().unwrap();
2095        let config = Config {
2096            address_book: scope::config::AddressBookConfig {
2097                data_dir: Some(tmp_dir.path().to_path_buf()),
2098            },
2099            ..Default::default()
2100        };
2101        let factory = mock_factory();
2102
2103        let add_args = AddressBookArgs {
2104            command: AddressBookCommands::Add(AddArgs {
2105                address: "0xSummaryMd".to_string(),
2106                label: Some("SummaryMarkdown".to_string()),
2107                chain: "ethereum".to_string(),
2108                tags: vec![],
2109            }),
2110            format: None,
2111        };
2112        super::run(add_args, &config, &factory, &quiet())
2113            .await
2114            .unwrap();
2115
2116        let summary_args = AddressBookArgs {
2117            command: AddressBookCommands::Summary(SummaryArgs {
2118                chain: None,
2119                tag: None,
2120                include_tokens: false,
2121                report: None,
2122            }),
2123            format: Some(OutputFormat::Markdown),
2124        };
2125        let result = super::run(summary_args, &config, &factory, &quiet()).await;
2126        assert!(result.is_ok());
2127    }
2128
2129    #[tokio::test]
2130    async fn test_run_address_book_summary_with_report_file() {
2131        let tmp_dir = tempfile::tempdir().unwrap();
2132        let report_path = tmp_dir.path().join("portfolio_report.md");
2133        let config = Config {
2134            address_book: scope::config::AddressBookConfig {
2135                data_dir: Some(tmp_dir.path().to_path_buf()),
2136            },
2137            ..Default::default()
2138        };
2139        let factory = mock_factory();
2140
2141        let add_args = AddressBookArgs {
2142            command: AddressBookCommands::Add(AddArgs {
2143                address: "0xReportTest".to_string(),
2144                label: Some("ReportAddr".to_string()),
2145                chain: "ethereum".to_string(),
2146                tags: vec![],
2147            }),
2148            format: None,
2149        };
2150        super::run(add_args, &config, &factory, &quiet())
2151            .await
2152            .unwrap();
2153
2154        let summary_args = AddressBookArgs {
2155            command: AddressBookCommands::Summary(SummaryArgs {
2156                chain: None,
2157                tag: None,
2158                include_tokens: false,
2159                report: Some(report_path.clone()),
2160            }),
2161            format: Some(OutputFormat::Table),
2162        };
2163        let result = super::run(summary_args, &config, &factory, &quiet()).await;
2164        assert!(result.is_ok());
2165        assert!(report_path.exists());
2166        let content = std::fs::read_to_string(&report_path).unwrap();
2167        assert!(content.contains("# Address Book Report"));
2168        assert!(content.contains("Report generated by Scope"));
2169    }
2170
2171    #[tokio::test]
2172    async fn test_run_address_book_summary_with_unsupported_chain() {
2173        let tmp_dir = tempfile::tempdir().unwrap();
2174        let config = Config {
2175            address_book: scope::config::AddressBookConfig {
2176                data_dir: Some(tmp_dir.path().to_path_buf()),
2177            },
2178            ..Default::default()
2179        };
2180
2181        let add_args = AddressBookArgs {
2182            command: AddressBookCommands::Add(AddArgs {
2183                address: "0xUnsupported".to_string(),
2184                label: None,
2185                chain: "unsupported_chain_xyz".to_string(),
2186                tags: vec![],
2187            }),
2188            format: None,
2189        };
2190        super::run(add_args, &config, &mock_factory(), &quiet())
2191            .await
2192            .unwrap();
2193
2194        let failing_factory = FailingChainClientFactory;
2195        let summary_args = AddressBookArgs {
2196            command: AddressBookCommands::Summary(SummaryArgs {
2197                chain: None,
2198                tag: None,
2199                include_tokens: false,
2200                report: None,
2201            }),
2202            format: Some(OutputFormat::Json),
2203        };
2204        let result = super::run(summary_args, &config, &failing_factory, &quiet()).await;
2205        assert!(result.is_ok());
2206    }
2207
2208    #[tokio::test]
2209    async fn test_run_address_book_format_override_from_args() {
2210        let tmp_dir = tempfile::tempdir().unwrap();
2211        let config = Config {
2212            address_book: scope::config::AddressBookConfig {
2213                data_dir: Some(tmp_dir.path().to_path_buf()),
2214            },
2215            ..Default::default()
2216        };
2217        let factory = mock_factory();
2218
2219        let add_args = AddressBookArgs {
2220            command: AddressBookCommands::Add(AddArgs {
2221                address: "0xFormatOverride".to_string(),
2222                label: None,
2223                chain: "ethereum".to_string(),
2224                tags: vec![],
2225            }),
2226            format: Some(OutputFormat::Json),
2227        };
2228        super::run(add_args, &config, &factory, &quiet())
2229            .await
2230            .unwrap();
2231
2232        let list_args = AddressBookArgs {
2233            command: AddressBookCommands::List,
2234            format: Some(OutputFormat::Json),
2235        };
2236        let result = super::run(list_args, &config, &factory, &quiet()).await;
2237        assert!(result.is_ok());
2238    }
2239}