• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

0xmichalis / nftbk / 19700594269

26 Nov 2025 10:26AM UTC coverage: 54.547% (-0.05%) from 54.592%
19700594269

push

github

0xmichalis
feat: log estimated size in cli requests

7 of 11 new or added lines in 2 files covered. (63.64%)

101 existing lines in 6 files now uncovered.

3227 of 5916 relevant lines covered (54.55%)

10.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.54
/src/cli/commands/server/create.rs
1
use std::env;
2
use std::fs::File;
3
use std::io::Cursor;
4
use std::path::PathBuf;
5

6
use anyhow::{Context, Result};
7
use flate2::read::GzDecoder;
8
use reqwest::Client;
9
use tar::Archive;
10
use zip::ZipArchive;
11

12
use crate::cli::config::load_token_config;
13
use crate::cli::x402::X402PaymentHandler;
14
use crate::envvar::is_defined;
15
use crate::server::api::{
16
    self, BackupCreateResponse, BackupRequest, BackupResponse, QuoteCreateResponse, QuoteResponse,
17
    Tokens,
18
};
19
use crate::server::archive::archive_format_from_user_agent;
20
use crate::server::hashing::compute_task_id;
21
use crate::size::format_size;
22

23
use super::common::delete_backup;
24

25
const BACKUPS_API_PATH: &str = "/v1/backups";
26
const QUOTES_API_PATH: &str = "/v1/backups/quote";
27

28
#[derive(Debug)]
29
enum BackupStart {
30
    Created(BackupCreateResponse),
31
    Exists(BackupCreateResponse),
32
    Conflict {
33
        retry_url: String,
34
        message: String,
35
        instance: Option<String>,
36
    },
37
}
38

39
fn extract_task_id_from_retry_url(retry_url: &str) -> Option<String> {
×
40
    // Expected format: {BACKUPS_API_PATH}/{task_id}/retry
41
    let prefix = format!("{}/", BACKUPS_API_PATH);
×
42
    if !retry_url.starts_with(&prefix) {
×
43
        return None;
×
44
    }
45
    let tail = &retry_url[prefix.len()..];
×
46
    let parts: Vec<&str> = tail.split('/').collect();
×
47
    if parts.is_empty() {
×
48
        return None;
×
49
    }
50
    Some(parts[0].to_string())
×
51
}
52

53
#[allow(clippy::too_many_arguments)]
54
async fn force_delete_and_retry(
×
55
    client: &Client,
56
    server_address: &str,
57
    auth_token: Option<&str>,
58
    retry_url: &str,
59
    instance_url: Option<&str>,
60
    token_config: &crate::TokenConfig,
61
    user_agent: &str,
62
    pin_on_ipfs: bool,
63
    x402_private_key: Option<&str>,
64
    quote_id: Option<&str>,
65
) -> Result<String> {
66
    let source = if !retry_url.is_empty() {
×
67
        Some(retry_url)
×
68
    } else {
69
        instance_url
×
70
    };
71
    if let Some(src) = source {
×
72
        if let Some(conflict_task_id) = extract_task_id_from_retry_url(src) {
×
73
            let _ = delete_backup(client, server_address, &conflict_task_id, auth_token).await;
×
74
            let start2 = request_backup(
75
                token_config,
×
76
                server_address,
×
77
                client,
×
78
                auth_token,
×
79
                user_agent,
×
80
                pin_on_ipfs,
×
81
                x402_private_key,
×
82
                quote_id,
×
83
            )
84
            .await?;
×
85
            return match start2 {
×
86
                BackupStart::Created(r) | BackupStart::Exists(r) => Ok(r.task_id.clone()),
×
87
                BackupStart::Conflict {
88
                    message, retry_url, ..
×
89
                } => anyhow::bail!(
×
90
                    "{}\nCould not create new task after force delete. Try POST to {}{}",
×
91
                    message,
92
                    server_address.trim_end_matches('/'),
×
93
                    retry_url
94
                ),
95
            };
96
        }
97
    }
98
    anyhow::bail!(
×
99
        "Could not parse conflict task id from server response (missing retry/instance URL)"
100
    )
101
}
102

103
pub async fn run(
1✔
104
    tokens_config_path: PathBuf,
105
    server_address: String,
106
    output_path: Option<PathBuf>,
107
    force: bool,
108
    user_agent: String,
109
    pin_on_ipfs: bool,
110
    polling_interval_ms: Option<u64>,
111
) -> Result<()> {
112
    let token_config = load_token_config(&tokens_config_path).await?;
3✔
113
    let auth_token = env::var("NFTBK_AUTH_TOKEN").ok();
3✔
114
    let x402_private_key = env::var("NFTBK_X402_PRIVATE_KEY").ok();
3✔
115
    let client = Client::new();
2✔
116

117
    // Build the backup request to compute task_id
118
    let mut backup_req = BackupRequest {
119
        tokens: Vec::new(),
1✔
120
        pin_on_ipfs,
121
        create_archive: true,
122
    };
123
    for (chain, tokens) in &token_config.chains {
5✔
124
        backup_req.tokens.push(Tokens {
3✔
125
            chain: chain.clone(),
3✔
126
            tokens: tokens.clone(),
1✔
127
        });
128
    }
129

130
    // Compute task_id to check if backup exists without making POST request
131
    let task_id = compute_task_id(&backup_req.tokens, auth_token.as_deref());
5✔
132

133
    // Check if backup exists using GET endpoint (no payment required)
134
    let backup_exists =
1✔
135
        check_backup_exists(&client, &server_address, &task_id, auth_token.as_deref()).await?;
6✔
136

137
    // Request a quote only if we need to create a backup (for dynamic pricing)
138
    // If quote endpoint is not available, continue without quote for backwards compatibility
139
    let polling_interval = polling_interval_ms.unwrap_or(10000);
3✔
140
    let quote_id = if backup_exists && !force {
2✔
141
        // Backup exists and we're not forcing, no need for quote
142
        None
×
143
    } else {
144
        // Need to create backup, request quote
145
        match request_quote(
1✔
146
            &token_config,
1✔
147
            &server_address,
1✔
148
            &client,
1✔
149
            auth_token.as_deref(),
2✔
150
            pin_on_ipfs,
1✔
151
            polling_interval,
1✔
152
        )
153
        .await
1✔
154
        {
155
            Ok(quote_id) => Some(quote_id),
×
156
            Err(e) => {
1✔
157
                tracing::warn!(
1✔
158
                    "Failed to request quote (server may not support quotes): {}",
×
159
                    e
160
                );
161
                None
1✔
162
            }
163
        }
164
    };
165

166
    let final_task_id = if backup_exists {
2✔
167
        println!("Task ID (exists): {task_id}");
×
168
        if force {
×
169
            // Delete existing backup and create new one
170
            let _ = delete_backup(&client, &server_address, &task_id, auth_token.as_deref()).await;
×
171
            let start = request_backup(
172
                &token_config,
×
173
                &server_address,
×
174
                &client,
×
175
                auth_token.as_deref(),
×
176
                &user_agent,
×
177
                pin_on_ipfs,
×
178
                x402_private_key.as_deref(),
×
179
                quote_id.as_deref(),
×
180
            )
181
            .await?;
×
182
            match start {
×
183
                BackupStart::Created(resp) | BackupStart::Exists(resp) => {
×
184
                    let task_id2 = resp.task_id.clone();
×
185
                    println!("Task ID (after delete): {task_id2}");
×
186
                    task_id2
×
187
                }
188
                BackupStart::Conflict {
189
                    message: _,
190
                    retry_url,
×
191
                    instance,
×
192
                } => {
193
                    let task_id2 = force_delete_and_retry(
194
                        &client,
×
195
                        &server_address,
×
196
                        auth_token.as_deref(),
×
197
                        &retry_url,
×
198
                        instance.as_deref(),
×
199
                        &token_config,
×
200
                        &user_agent,
×
201
                        pin_on_ipfs,
×
202
                        x402_private_key.as_deref(),
×
203
                        quote_id.as_deref(),
×
204
                    )
205
                    .await?;
×
206
                    println!("Task ID (after force delete): {task_id2}");
×
207
                    task_id2
×
208
                }
209
            }
210
        } else {
211
            task_id
×
212
        }
213
    } else {
214
        // Backup doesn't exist, create it
215
        let start = request_backup(
216
            &token_config,
2✔
217
            &server_address,
2✔
218
            &client,
2✔
219
            auth_token.as_deref(),
3✔
220
            &user_agent,
2✔
221
            pin_on_ipfs,
2✔
222
            x402_private_key.as_deref(),
3✔
223
            quote_id.as_deref(),
2✔
224
        )
225
        .await?;
1✔
226
        match start {
1✔
227
            BackupStart::Created(resp) => {
1✔
228
                let task_id = resp.task_id.clone();
3✔
229
                println!("Task ID: {task_id}");
2✔
230
                task_id
1✔
231
            }
232
            BackupStart::Exists(resp) => {
×
233
                let task_id = resp.task_id.clone();
×
234
                println!("Task ID (exists): {task_id}");
×
235
                task_id
×
236
            }
237
            BackupStart::Conflict {
238
                retry_url,
×
239
                message: _,
240
                instance,
×
241
            } => {
242
                if force {
×
243
                    let task_id = force_delete_and_retry(
244
                        &client,
×
245
                        &server_address,
×
246
                        auth_token.as_deref(),
×
247
                        &retry_url,
×
248
                        instance.as_deref(),
×
249
                        &token_config,
×
250
                        &user_agent,
×
251
                        pin_on_ipfs,
×
252
                        x402_private_key.as_deref(),
×
253
                        quote_id.as_deref(),
×
254
                    )
255
                    .await?;
×
256
                    println!("Task ID (after force delete): {task_id}");
×
257
                    task_id
×
258
                } else {
259
                    anyhow::bail!(
×
260
                        "Conflict creating backup. Re-run with --force true, or POST to {}{}",
×
261
                        server_address.trim_end_matches('/'),
×
262
                        retry_url
263
                    );
264
                }
265
            }
266
        }
267
    };
268

269
    let polling_interval = polling_interval_ms.unwrap_or(10000); // Default 10 seconds
3✔
270
    wait_for_done_backup(
271
        &client,
2✔
272
        &server_address,
2✔
273
        &final_task_id,
2✔
274
        auth_token.as_deref(),
3✔
275
        polling_interval,
1✔
276
    )
277
    .await?;
1✔
278

279
    return download_backup(
2✔
280
        &client,
2✔
281
        &server_address,
2✔
282
        &final_task_id,
2✔
283
        output_path.as_ref(),
3✔
284
        auth_token.as_deref(),
3✔
285
        &archive_format_from_user_agent(&user_agent),
1✔
286
    )
287
    .await;
1✔
288
}
289

290
async fn request_quote(
3✔
291
    token_config: &crate::TokenConfig,
292
    server_address: &str,
293
    client: &Client,
294
    auth_token: Option<&str>,
295
    pin_on_ipfs: bool,
296
    polling_interval_ms: u64,
297
) -> Result<String> {
298
    let mut backup_req = BackupRequest {
299
        tokens: Vec::new(),
3✔
300
        pin_on_ipfs,
301
        create_archive: true,
302
    };
303
    for (chain, tokens) in &token_config.chains {
15✔
304
        backup_req.tokens.push(Tokens {
9✔
305
            chain: chain.clone(),
9✔
306
            tokens: tokens.clone(),
3✔
307
        });
308
    }
309

310
    let server = server_address.trim_end_matches('/');
9✔
311
    println!("Requesting quote from server at {server}{QUOTES_API_PATH} ...");
6✔
312
    let mut req_builder = client
6✔
313
        .post(format!("{server}{QUOTES_API_PATH}"))
9✔
314
        .json(&backup_req);
6✔
315
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
12✔
316
        req_builder =
×
317
            req_builder.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
318
    }
319
    let resp = req_builder
9✔
320
        .send()
321
        .await
3✔
322
        .context("Failed to send quote request to server")?;
323

324
    let status = resp.status();
9✔
325
    if !status.is_success() {
3✔
326
        let text = resp.text().await.unwrap_or_default();
8✔
327
        anyhow::bail!("Failed to request quote: {} - {}", status, text);
4✔
328
    }
329

330
    let quote_resp: QuoteCreateResponse = resp
4✔
331
        .json()
332
        .await
1✔
333
        .context("Invalid quote response from server")?;
334
    let quote_id = quote_resp.quote_id.clone();
3✔
335
    println!("Quote ID: {quote_id}");
2✔
336

337
    // Poll for quote to be ready
338
    println!("Waiting for quote to be ready...");
2✔
339
    let quote_url = format!("{server}{QUOTES_API_PATH}/{quote_id}");
3✔
340
    loop {
341
        tokio::time::sleep(tokio::time::Duration::from_millis(polling_interval_ms)).await;
3✔
342
        let mut get_req = client.get(&quote_url);
4✔
343
        if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
4✔
344
            get_req = get_req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
345
        }
346
        let get_resp = get_req
3✔
347
            .send()
348
            .await
1✔
349
            .context("Failed to check quote status")?;
350

351
        let get_status = get_resp.status();
3✔
352
        if !get_status.is_success() {
1✔
353
            let text = get_resp.text().await.unwrap_or_default();
×
354
            anyhow::bail!("Failed to get quote: {} - {}", get_status, text);
×
355
        }
356

357
        let quote: QuoteResponse = get_resp.json().await.context("Invalid quote response")?;
5✔
358

359
        if let Some(price_microdollars) = &quote.price {
2✔
360
            let price_decimal = price_microdollars
2✔
361
                .parse::<u64>()
362
                .map_err(|_| anyhow::anyhow!("Invalid price format: {}", price_microdollars))?;
1✔
363
            let price_human = price_decimal as f64 / 1_000_000.0;
2✔
364
            if let Some(size_bytes) = quote.estimated_size_bytes {
1✔
NEW
365
                let (size_value, size_unit) = format_size(size_bytes);
×
NEW
366
                println!(
×
NEW
367
                    "\nQuote ready! Price: {} USDC (estimated size ≈ {:.2} {})",
×
NEW
368
                    price_human, size_value, size_unit
×
369
                );
370
            } else {
371
                println!("\nQuote ready! Price: {} USDC", price_human);
1✔
372
            }
373
            return Ok(quote_id);
1✔
374
        }
375

376
        // Still processing, continue polling
377
        print!(".");
×
378
        use std::io::Write;
379
        std::io::stdout().flush().ok();
×
380
    }
381
}
382

383
#[allow(clippy::too_many_arguments)]
384
async fn request_backup(
7✔
385
    token_config: &crate::TokenConfig,
386
    server_address: &str,
387
    client: &Client,
388
    auth_token: Option<&str>,
389
    user_agent: &str,
390
    pin_on_ipfs: bool,
391
    x402_private_key: Option<&str>,
392
    quote_id: Option<&str>,
393
) -> Result<BackupStart> {
394
    let mut backup_req = BackupRequest {
395
        tokens: Vec::new(),
7✔
396
        pin_on_ipfs,
397
        create_archive: true,
398
    };
399
    for (chain, tokens) in &token_config.chains {
35✔
400
        backup_req.tokens.push(Tokens {
21✔
401
            chain: chain.clone(),
21✔
402
            tokens: tokens.clone(),
7✔
403
        });
404
    }
405

406
    let server = server_address.trim_end_matches('/');
21✔
407
    println!("Submitting backup request to server at {server}{BACKUPS_API_PATH} ...");
14✔
408
    let mut req_builder = client
14✔
409
        .post(format!("{server}{BACKUPS_API_PATH}"))
21✔
410
        .json(&backup_req);
14✔
411
    req_builder = req_builder.header("User-Agent", user_agent);
28✔
412
    if let Some(quote_id) = quote_id {
7✔
413
        req_builder = req_builder.header("X-Quote-Id", quote_id);
×
414
        println!("Using quote ID: {quote_id}");
×
415
    }
416
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
31✔
417
        req_builder =
2✔
418
            req_builder.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
5✔
419
    }
420
    let req = req_builder.build().context("Failed to build request")?;
28✔
421
    let mut resp = client
14✔
422
        .execute(req.try_clone().context("Failed to clone request")?)
28✔
423
        .await
7✔
424
        .context("Failed to send backup request to server")?;
425
    let mut status = resp.status();
21✔
426

427
    // Handle 402 Payment Required response
428
    if status.as_u16() == 402 {
7✔
429
        resp = handle_402_response(client, resp, &req, x402_private_key).await?;
×
430
        status = resp.status();
×
431
    }
432

433
    if status.is_success() {
14✔
434
        let backup_resp: BackupCreateResponse =
10✔
435
            resp.json().await.context("Invalid server response")?;
15✔
436
        if status.as_u16() == 201 {
5✔
437
            return Ok(BackupStart::Created(backup_resp));
4✔
438
        } else {
439
            return Ok(BackupStart::Exists(backup_resp));
1✔
440
        }
441
    }
442
    if status.as_u16() == 409 {
2✔
443
        let body: serde_json::Value = resp
4✔
444
            .json()
445
            .await
1✔
446
            .context("Invalid conflict response from server")?;
447
        let retry_url = body
2✔
448
            .get("retry_url")
449
            .and_then(|v| v.as_str())
3✔
450
            .unwrap_or("")
451
            .to_string();
452
        let message = body
2✔
453
            .get("error")
454
            .and_then(|v| v.as_str())
3✔
455
            .unwrap_or(&format!("Server returned conflict for {BACKUPS_API_PATH}"))
2✔
456
            .to_string();
457
        let instance = body
2✔
458
            .get("instance")
459
            .and_then(|v| v.as_str())
1✔
460
            .map(|s| s.to_string());
1✔
461
        return Ok(BackupStart::Conflict {
1✔
462
            retry_url,
2✔
463
            message,
1✔
464
            instance,
1✔
465
        });
466
    }
467
    let text = resp.text().await.unwrap_or_default();
4✔
468
    anyhow::bail!("Server error: {}", text);
2✔
469
}
470

471
/// Handle 402 Payment Required response by creating a payment and retrying the request
472
async fn handle_402_response(
×
473
    client: &Client,
474
    response: reqwest::Response,
475
    original_request: &reqwest::Request,
476
    x402_private_key: Option<&str>,
477
) -> Result<reqwest::Response> {
478
    // Parse the 402 response structure
479
    let response_text = response
×
480
        .text()
481
        .await
×
482
        .context("Failed to read 402 response body")?;
483
    let response_json: serde_json::Value =
×
484
        serde_json::from_str(&response_text).context("Failed to parse 402 response as JSON")?;
×
485

486
    // Extract PaymentRequirements from the accepts array
487
    let accepts = response_json
×
488
        .get("accepts")
489
        .and_then(|v| v.as_array())
×
490
        .context("Missing or invalid 'accepts' field in 402 response")?;
491

492
    if accepts.is_empty() {
×
493
        anyhow::bail!("No payment options available in 402 response");
×
494
    }
495

496
    // Use the first payment option
497
    let payment_requirements: x402_rs::types::PaymentRequirements =
×
498
        serde_json::from_value(accepts[0].clone())
×
499
            .context("Failed to parse PaymentRequirements from accepts array")?;
500

501
    // Create x402 payment handler
502
    let payment_handler = X402PaymentHandler::new(x402_private_key)
×
503
        .context("Failed to create x402 payment handler")?;
504

505
    // Handle the payment and retry the request
506
    let response = payment_handler
×
507
        .handle_402_response(client, original_request, payment_requirements)
×
508
        .await
×
509
        .context("Failed to handle x402 payment")?;
510

511
    Ok(response)
×
512
}
513

514
/// Check if a backup exists by making a GET request to the backup status endpoint
515
async fn check_backup_exists(
1✔
516
    client: &Client,
517
    server_address: &str,
518
    task_id: &str,
519
    auth_token: Option<&str>,
520
) -> Result<bool> {
521
    let server = server_address.trim_end_matches('/');
3✔
522
    let mut req_builder = client.get(format!("{server}/v1/backups/{task_id}"));
5✔
523

524
    if let Some(token) = auth_token {
1✔
525
        req_builder = req_builder.header("Authorization", format!("Bearer {}", token));
×
526
    }
527

528
    let resp = req_builder.send().await?;
3✔
529
    let status = resp.status();
3✔
530

531
    match status.as_u16() {
1✔
532
        200 => Ok(true),  // Backup exists
×
533
        404 => Ok(false), // Backup doesn't exist
1✔
534
        _ => {
535
            let text = resp.text().await.unwrap_or_default();
×
536
            anyhow::bail!("Failed to check backup status: {} - {}", status, text);
×
537
        }
538
    }
539
}
540

541
async fn wait_for_done_backup(
4✔
542
    client: &reqwest::Client,
543
    server_address: &str,
544
    task_id: &str,
545
    auth_token: Option<&str>,
546
    polling_interval_ms: u64,
547
) -> Result<()> {
548
    let status_url = format!(
8✔
549
        "{}{}/{}",
550
        server_address.trim_end_matches('/'),
8✔
551
        BACKUPS_API_PATH,
552
        task_id
553
    );
554
    let mut in_progress_logged = false;
8✔
555
    loop {
556
        let mut req = client.get(&status_url);
16✔
557
        if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
19✔
558
            req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
6✔
559
        }
560
        let resp = req.send().await;
12✔
561
        match resp {
4✔
562
            Ok(r) => {
4✔
563
                if r.status().is_success() {
8✔
564
                    let status: BackupResponse = r.json().await.unwrap_or({
20✔
565
                        // Fallback shape with nulls
566
                        BackupResponse {
4✔
567
                            task_id: task_id.to_string(),
12✔
568
                            created_at: String::new(),
8✔
569
                            storage_mode: String::new(),
8✔
570
                            tokens: Vec::new(),
8✔
571
                            total_tokens: 0,
4✔
572
                            page: 1,
4✔
573
                            limit: 50,
4✔
574
                            archive: Some(api::Archive {
8✔
575
                                status: api::SubresourceStatus {
12✔
576
                                    status: None,
16✔
577
                                    fatal_error: None,
16✔
578
                                    error_log: None,
12✔
579
                                    deleted_at: None,
12✔
580
                                },
581
                                format: None,
8✔
582
                                expires_at: None,
8✔
583
                            }),
584
                            pins: Some(api::Pins {
4✔
585
                                status: api::SubresourceStatus {
4✔
586
                                    status: None,
8✔
587
                                    fatal_error: None,
8✔
588
                                    error_log: None,
4✔
589
                                    deleted_at: None,
4✔
590
                                },
591
                            }),
592
                        }
593
                    });
594
                    // Aggregate a coarse "overall" view for UX: in_progress if any subresource is in_progress
595
                    let archive_status = status
8✔
596
                        .archive
4✔
597
                        .as_ref()
598
                        .and_then(|a| a.status.status.as_deref());
12✔
599
                    let ipfs_status = status
8✔
600
                        .pins
4✔
601
                        .as_ref()
602
                        .and_then(|p| p.status.status.as_deref());
12✔
603
                    let any_in_progress = matches!(archive_status, Some("in_progress"))
12✔
604
                        || matches!(ipfs_status, Some("in_progress"));
8✔
605
                    let any_error = matches!(archive_status, Some("error"))
13✔
606
                        || matches!(ipfs_status, Some("error"))
6✔
607
                        || status
3✔
608
                            .archive
3✔
609
                            .as_ref()
3✔
610
                            .and_then(|a| a.status.fatal_error.as_ref())
9✔
611
                            .is_some()
3✔
612
                        || status
3✔
613
                            .pins
3✔
614
                            .as_ref()
3✔
615
                            .and_then(|p| p.status.fatal_error.as_ref())
9✔
616
                            .is_some();
3✔
617
                    let all_done = matches!(archive_status, Some("done"))
15✔
618
                        && (ipfs_status.is_none() || matches!(ipfs_status, Some("done")));
15✔
619
                    if any_in_progress {
4✔
620
                        if !in_progress_logged {
×
621
                            println!("Waiting for backup to complete...");
×
622
                            in_progress_logged = true;
×
623
                        }
624
                    } else if all_done {
4✔
625
                        println!("Backup complete.");
6✔
626
                        if let Some(ref a) = status
3✔
627
                            .archive
3✔
628
                            .as_ref()
629
                            .and_then(|a| a.status.error_log.as_ref())
9✔
630
                        {
631
                            if !a.is_empty() {
×
632
                                tracing::warn!("{}", a);
×
633
                            }
634
                        }
635
                        if let Some(ref i) = status
3✔
636
                            .pins
3✔
637
                            .as_ref()
638
                            .and_then(|p| p.status.error_log.as_ref())
9✔
639
                        {
640
                            if !i.is_empty() {
×
641
                                tracing::warn!("{}", i);
×
642
                            }
643
                        }
644
                        break;
3✔
645
                    } else if any_error {
1✔
646
                        let msg = status
2✔
647
                            .archive
1✔
648
                            .as_ref()
649
                            .and_then(|a| a.status.fatal_error.clone())
3✔
650
                            .or(status
2✔
651
                                .pins
1✔
652
                                .as_ref()
1✔
653
                                .and_then(|p| p.status.fatal_error.clone()))
3✔
654
                            .unwrap_or_else(|| "Unknown error".to_string());
1✔
655
                        anyhow::bail!("Server error: {}", msg);
2✔
656
                    } else {
657
                        println!(
×
658
                            "Unknown status: archive={:?} ipfs={:?}",
×
659
                            status
×
660
                                .archive
×
661
                                .as_ref()
×
662
                                .and_then(|a| a.status.status.as_deref()),
×
663
                            status
×
664
                                .pins
×
665
                                .as_ref()
×
666
                                .and_then(|p| p.status.status.as_deref())
×
667
                        );
668
                    }
669
                } else {
670
                    println!("Failed to get status: {}", r.status());
×
671
                }
672
            }
673
            Err(e) => {
×
674
                println!("Error polling status: {e}");
×
675
            }
676
        }
677
        tokio::time::sleep(std::time::Duration::from_millis(polling_interval_ms)).await;
×
678
    }
679
    Ok(())
3✔
680
}
681

682
async fn download_backup(
6✔
683
    client: &Client,
684
    server_address: &str,
685
    task_id: &str,
686
    output_path: Option<&PathBuf>,
687
    _auth_token: Option<&str>,
688
    archive_format: &str,
689
) -> Result<()> {
690
    // Step 1: Get download token
691
    let token_url = format!(
12✔
692
        "{}{}/{}/download-tokens",
693
        server_address.trim_end_matches('/'),
12✔
694
        BACKUPS_API_PATH,
695
        task_id
696
    );
697
    let mut token_req = client.post(&token_url);
24✔
698
    if is_defined(&_auth_token.as_ref().map(|s| s.to_string())) {
24✔
699
        token_req = token_req.header("Authorization", format!("Bearer {}", _auth_token.unwrap()));
×
700
    }
701
    let token_resp = token_req
18✔
702
        .send()
703
        .await
6✔
704
        .context("Failed to get download token")?;
705
    if !token_resp.status().is_success() {
6✔
706
        anyhow::bail!("Failed to get download token: {}", token_resp.status());
4✔
707
    }
708
    let token_json: serde_json::Value =
10✔
709
        token_resp.json().await.context("Invalid token response")?;
15✔
710
    let download_token = token_json
10✔
711
        .get("token")
712
        .and_then(|v| v.as_str())
15✔
713
        .ok_or_else(|| anyhow::anyhow!("No token in response"))?;
5✔
714

715
    // Step 2: Download using token
716
    let download_url = format!(
10✔
717
        "{}{}/{}/download?token={}",
718
        server_address.trim_end_matches('/'),
10✔
719
        BACKUPS_API_PATH,
720
        task_id,
721
        urlencoding::encode(download_token)
10✔
722
    );
723
    println!("Downloading archive ...");
10✔
724
    let resp = client
15✔
725
        .get(&download_url)
15✔
726
        .send()
727
        .await
5✔
728
        .context("Failed to download archive")?;
729
    if !resp.status().is_success() {
5✔
730
        anyhow::bail!("Failed to download archive: {}", resp.status());
4✔
731
    }
732
    let bytes = resp.bytes().await.context("Failed to read archive bytes")?;
16✔
733
    let output_path = output_path.cloned().unwrap_or_else(|| PathBuf::from("."));
17✔
734

735
    println!("Extracting backup to {}...", output_path.display());
12✔
736
    match archive_format {
4✔
737
        "tar.gz" => {
4✔
738
            let gz = GzDecoder::new(Cursor::new(bytes));
8✔
739
            let mut archive = Archive::new(gz);
6✔
740
            archive
2✔
741
                .unpack(&output_path)
4✔
742
                .context("Failed to extract backup archive (tar.gz)")?;
743
        }
744
        "zip" => {
2✔
745
            let mut zip =
1✔
746
                ZipArchive::new(Cursor::new(bytes)).context("Failed to read zip archive")?;
4✔
747
            for i in 0..zip.len() {
3✔
748
                let mut file = zip.by_index(i).context("Failed to access file in zip")?;
5✔
749
                let outpath = match file.enclosed_name() {
2✔
750
                    Some(path) => output_path.join(path),
3✔
751
                    None => continue,
×
752
                };
753
                if file.name().ends_with('/') {
2✔
754
                    std::fs::create_dir_all(&outpath)
×
755
                        .context("Failed to create directory from zip")?;
756
                } else {
757
                    if let Some(p) = outpath.parent() {
2✔
758
                        std::fs::create_dir_all(p)
2✔
759
                            .context("Failed to create parent directory for zip file")?;
760
                    }
761
                    let mut outfile =
1✔
762
                        File::create(&outpath).context("Failed to create file from zip")?;
3✔
763
                    std::io::copy(&mut file, &mut outfile)
3✔
764
                        .context("Failed to extract file from zip")?;
765
                }
766
            }
767
        }
768
        _ => anyhow::bail!("Unknown archive format: {archive_format}"),
2✔
769
    }
770
    println!("Backup extracted to {}", output_path.display());
9✔
771
    Ok(())
3✔
772
}
773

774
#[cfg(test)]
775
mod tests {
776
    use super::*;
777
    use crate::types::TokenConfig;
778
    use std::collections::HashMap;
779
    use tempfile::TempDir;
780
    use wiremock::matchers::{header, method, path, path_regex, query_param};
781
    use wiremock::{Mock, MockServer, ResponseTemplate};
782

783
    fn create_test_token_config() -> TokenConfig {
784
        let mut chains = HashMap::new();
785
        chains.insert("ethereum".to_string(), vec!["0x123:1".to_string()]);
786
        TokenConfig { chains }
787
    }
788

789
    fn create_test_backup_create_response() -> BackupCreateResponse {
790
        BackupCreateResponse {
791
            task_id: "test-task-123".to_string(),
792
        }
793
    }
794

795
    fn create_test_backup_response() -> BackupResponse {
796
        BackupResponse {
797
            task_id: "test-task-123".to_string(),
798
            created_at: "2023-01-01T00:00:00Z".to_string(),
799
            storage_mode: "full".to_string(),
800
            tokens: vec![],
801
            total_tokens: 5,
802
            page: 1,
803
            limit: 50,
804
            archive: Some(api::Archive {
805
                status: api::SubresourceStatus {
806
                    status: Some("done".to_string()),
807
                    fatal_error: None,
808
                    error_log: None,
809
                    deleted_at: None,
810
                },
811
                format: Some("zip".to_string()),
812
                expires_at: None,
813
            }),
814
            pins: Some(api::Pins {
815
                status: api::SubresourceStatus {
816
                    status: Some("done".to_string()),
817
                    fatal_error: None,
818
                    error_log: None,
819
                    deleted_at: None,
820
                },
821
            }),
822
        }
823
    }
824

825
    fn create_test_quote_create_response(quote_id: &str) -> QuoteCreateResponse {
826
        QuoteCreateResponse {
827
            quote_id: quote_id.to_string(),
828
        }
829
    }
830

831
    fn create_test_quote_response(quote_id: &str, price: Option<&str>) -> QuoteResponse {
832
        let price_wei = price.and_then(|p| {
833
            crate::server::x402::parse_usdc_price_to_wei(p)
834
                .map(|wei| wei.to_string())
835
                .ok()
836
        });
837
        QuoteResponse {
838
            quote_id: quote_id.to_string(),
839
            price: price_wei,
840
            asset_symbol: None,
841
            network: None,
842
            estimated_size_bytes: None,
843
        }
844
    }
845

846
    mod request_quote_tests {
847
        use super::*;
848

849
        #[tokio::test]
850
        async fn returns_quote_id_when_quote_ready() {
851
            let mock_server = MockServer::start().await;
852
            let server_address = mock_server.uri();
853
            let token_config = create_test_token_config();
854
            let quote_id = "test-quote-123";
855

856
            let quote_create_response = create_test_quote_create_response(quote_id);
857
            Mock::given(method("POST"))
858
                .and(path("/v1/backups/quote"))
859
                .respond_with(ResponseTemplate::new(200).set_body_json(&quote_create_response))
860
                .mount(&mock_server)
861
                .await;
862

863
            let quote_ready_response = create_test_quote_response(quote_id, Some("10.5"));
864
            Mock::given(method("GET"))
865
                .and(path_regex(format!(r"^/v1/backups/quote/{quote_id}$")))
866
                .respond_with(ResponseTemplate::new(200).set_body_json(&quote_ready_response))
867
                .mount(&mock_server)
868
                .await;
869

870
            let client = Client::new();
871
            let result =
872
                request_quote(&token_config, &server_address, &client, None, false, 5).await;
873

874
            assert!(result.is_ok());
875
            assert_eq!(result.unwrap(), quote_id);
876
        }
877

878
        #[tokio::test]
879
        async fn errors_on_quote_creation_failure() {
880
            let mock_server = MockServer::start().await;
881
            let server_address = mock_server.uri();
882
            let token_config = create_test_token_config();
883

884
            Mock::given(method("POST"))
885
                .and(path("/v1/backups/quote"))
886
                .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
887
                .mount(&mock_server)
888
                .await;
889

890
            let client = Client::new();
891
            let result =
892
                request_quote(&token_config, &server_address, &client, None, false, 5).await;
893

894
            assert!(result.is_err());
895
        }
896
    }
897

898
    mod request_backup_tests {
899
        use super::*;
900

901
        #[tokio::test]
902
        async fn creates_new_backup_successfully() {
903
            let mock_server = MockServer::start().await;
904
            let server_address = mock_server.uri();
905
            let token_config = create_test_token_config();
906

907
            let backup_response = create_test_backup_create_response();
908

909
            Mock::given(method("POST"))
910
                .and(path("/v1/backups"))
911
                .respond_with(ResponseTemplate::new(201).set_body_json(&backup_response))
912
                .mount(&mock_server)
913
                .await;
914

915
            let client = Client::new();
916
            let result = request_backup(
917
                &token_config,
918
                &server_address,
919
                &client,
920
                None,
921
                "TestAgent",
922
                false,
923
                None,
924
                None,
925
            )
926
            .await;
927

928
            assert!(result.is_ok());
929
            match result.unwrap() {
930
                BackupStart::Created(resp) => {
931
                    assert_eq!(resp.task_id, "test-task-123");
932
                }
933
                _ => panic!("Expected Created variant"),
934
            }
935
        }
936

937
        #[tokio::test]
938
        async fn handles_existing_backup() {
939
            let mock_server = MockServer::start().await;
940
            let server_address = mock_server.uri();
941
            let token_config = create_test_token_config();
942

943
            let backup_response = create_test_backup_create_response();
944

945
            Mock::given(method("POST"))
946
                .and(path("/v1/backups"))
947
                .respond_with(ResponseTemplate::new(200).set_body_json(&backup_response))
948
                .mount(&mock_server)
949
                .await;
950

951
            let client = Client::new();
952
            let result = request_backup(
953
                &token_config,
954
                &server_address,
955
                &client,
956
                None,
957
                "TestAgent",
958
                false,
959
                None,
960
                None,
961
            )
962
            .await;
963

964
            assert!(result.is_ok());
965
            match result.unwrap() {
966
                BackupStart::Exists(resp) => {
967
                    assert_eq!(resp.task_id, "test-task-123");
968
                }
969
                _ => panic!("Expected Exists variant"),
970
            }
971
        }
972

973
        #[tokio::test]
974
        async fn handles_conflict_response() {
975
            let mock_server = MockServer::start().await;
976
            let server_address = mock_server.uri();
977
            let token_config = create_test_token_config();
978

979
            let conflict_response = serde_json::json!({
980
                "task_id": "conflict-task-456",
981
                "retry_url": "/v1/backups/conflict-task-456/retry",
982
                "error": "Task already in progress"
983
            });
984

985
            Mock::given(method("POST"))
986
                .and(path("/v1/backups"))
987
                .respond_with(ResponseTemplate::new(409).set_body_json(&conflict_response))
988
                .mount(&mock_server)
989
                .await;
990

991
            let client = Client::new();
992
            let result = request_backup(
993
                &token_config,
994
                &server_address,
995
                &client,
996
                None,
997
                "TestAgent",
998
                false,
999
                None,
1000
                None,
1001
            )
1002
            .await;
1003

1004
            assert!(result.is_ok());
1005
            match result.unwrap() {
1006
                BackupStart::Conflict {
1007
                    retry_url, message, ..
1008
                } => {
1009
                    assert_eq!(retry_url, "/v1/backups/conflict-task-456/retry");
1010
                    assert_eq!(message, "Task already in progress");
1011
                }
1012
                _ => panic!("Expected Conflict variant"),
1013
            }
1014
        }
1015

1016
        #[tokio::test]
1017
        async fn includes_auth_header_when_token_present() {
1018
            let mock_server = MockServer::start().await;
1019
            let server_address = mock_server.uri();
1020
            let token_config = create_test_token_config();
1021

1022
            let backup_response = create_test_backup_create_response();
1023

1024
            Mock::given(method("POST"))
1025
                .and(path("/v1/backups"))
1026
                .and(header("Authorization", "Bearer test-token-123"))
1027
                .respond_with(ResponseTemplate::new(201).set_body_json(&backup_response))
1028
                .mount(&mock_server)
1029
                .await;
1030

1031
            let client = Client::new();
1032
            let result = request_backup(
1033
                &token_config,
1034
                &server_address,
1035
                &client,
1036
                Some("test-token-123"),
1037
                "TestAgent",
1038
                false,
1039
                None,
1040
                None,
1041
            )
1042
            .await;
1043

1044
            assert!(result.is_ok());
1045
        }
1046

1047
        #[tokio::test]
1048
        async fn includes_user_agent_header() {
1049
            let mock_server = MockServer::start().await;
1050
            let server_address = mock_server.uri();
1051
            let token_config = create_test_token_config();
1052

1053
            let backup_response = create_test_backup_create_response();
1054

1055
            Mock::given(method("POST"))
1056
                .and(path("/v1/backups"))
1057
                .and(header("User-Agent", "CustomAgent/1.0"))
1058
                .respond_with(ResponseTemplate::new(201).set_body_json(&backup_response))
1059
                .mount(&mock_server)
1060
                .await;
1061

1062
            let client = Client::new();
1063
            let result = request_backup(
1064
                &token_config,
1065
                &server_address,
1066
                &client,
1067
                None,
1068
                "CustomAgent/1.0",
1069
                false,
1070
                None,
1071
                None,
1072
            )
1073
            .await;
1074

1075
            assert!(result.is_ok());
1076
        }
1077

1078
        #[tokio::test]
1079
        async fn handles_server_error() {
1080
            let mock_server = MockServer::start().await;
1081
            let server_address = mock_server.uri();
1082
            let token_config = create_test_token_config();
1083

1084
            Mock::given(method("POST"))
1085
                .and(path("/v1/backups"))
1086
                .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
1087
                .mount(&mock_server)
1088
                .await;
1089

1090
            let client = Client::new();
1091
            let result = request_backup(
1092
                &token_config,
1093
                &server_address,
1094
                &client,
1095
                None,
1096
                "TestAgent",
1097
                false,
1098
                None,
1099
                None,
1100
            )
1101
            .await;
1102

1103
            assert!(result.is_err());
1104
            assert!(result.unwrap_err().to_string().contains("Server error"));
1105
        }
1106
    }
1107

1108
    mod wait_for_done_backup_tests {
1109
        use super::*;
1110

1111
        #[tokio::test]
1112
        async fn waits_for_backup_completion() {
1113
            let mock_server = MockServer::start().await;
1114
            let server_address = mock_server.uri();
1115
            let task_id = "test-task-123";
1116

1117
            let backup_response = create_test_backup_response();
1118

1119
            Mock::given(method("GET"))
1120
                .and(path(format!("/v1/backups/{}", task_id)))
1121
                .respond_with(ResponseTemplate::new(200).set_body_json(&backup_response))
1122
                .mount(&mock_server)
1123
                .await;
1124

1125
            let client = Client::new();
1126
            let result = wait_for_done_backup(&client, &server_address, task_id, None, 10).await;
1127

1128
            assert!(result.is_ok());
1129
        }
1130

1131
        #[tokio::test]
1132
        async fn handles_backup_with_errors() {
1133
            let mock_server = MockServer::start().await;
1134
            let server_address = mock_server.uri();
1135
            let task_id = "test-task-error";
1136

1137
            let mut backup_response = create_test_backup_response();
1138
            backup_response.archive.as_mut().unwrap().status.status = Some("error".to_string());
1139
            backup_response.archive.as_mut().unwrap().status.fatal_error =
1140
                Some("Archive creation failed".to_string());
1141

1142
            Mock::given(method("GET"))
1143
                .and(path(format!("/v1/backups/{}", task_id)))
1144
                .respond_with(ResponseTemplate::new(200).set_body_json(&backup_response))
1145
                .mount(&mock_server)
1146
                .await;
1147

1148
            let client = Client::new();
1149
            let result = wait_for_done_backup(&client, &server_address, task_id, None, 10).await;
1150

1151
            if result.is_ok() {
1152
                println!("Expected error but got Ok result");
1153
            }
1154
            assert!(result.is_err());
1155
            assert!(result.unwrap_err().to_string().contains("Server error"));
1156
        }
1157

1158
        #[tokio::test]
1159
        async fn includes_auth_header_when_token_present() {
1160
            let mock_server = MockServer::start().await;
1161
            let server_address = mock_server.uri();
1162
            let task_id = "test-task-auth";
1163

1164
            let backup_response = create_test_backup_response();
1165

1166
            Mock::given(method("GET"))
1167
                .and(path(format!("/v1/backups/{}", task_id)))
1168
                .and(header("Authorization", "Bearer test-token-123"))
1169
                .respond_with(ResponseTemplate::new(200).set_body_json(&backup_response))
1170
                .mount(&mock_server)
1171
                .await;
1172

1173
            let client = Client::new();
1174
            let result = wait_for_done_backup(
1175
                &client,
1176
                &server_address,
1177
                task_id,
1178
                Some("test-token-123"),
1179
                10,
1180
            )
1181
            .await;
1182

1183
            assert!(result.is_ok());
1184
        }
1185
    }
1186

1187
    mod download_backup_tests {
1188
        use super::*;
1189

1190
        #[tokio::test]
1191
        async fn downloads_and_extracts_zip_archive() {
1192
            let mock_server = MockServer::start().await;
1193
            let server_address = mock_server.uri();
1194
            let task_id = "test-task-123";
1195

1196
            // Create a temporary directory for extraction
1197
            let temp_dir = TempDir::new().unwrap();
1198
            let output_path = temp_dir.path();
1199

1200
            // Mock download token response
1201
            let token_response = serde_json::json!({
1202
                "token": "download-token-123"
1203
            });
1204

1205
            Mock::given(method("POST"))
1206
                .and(path(format!("/v1/backups/{}/download-tokens", task_id)))
1207
                .respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
1208
                .mount(&mock_server)
1209
                .await;
1210

1211
            // Create a simple zip file content
1212
            let mut zip_data = Vec::new();
1213
            {
1214
                use std::io::Write;
1215
                let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_data));
1216
                zip.start_file("test.txt", zip::write::FileOptions::default())
1217
                    .unwrap();
1218
                zip.write_all(b"Hello, World!").unwrap();
1219
                zip.finish().unwrap();
1220
            }
1221

1222
            // Mock download response
1223
            Mock::given(method("GET"))
1224
                .and(path(format!("/v1/backups/{}/download", task_id)))
1225
                .and(query_param("token", "download-token-123"))
1226
                .respond_with(ResponseTemplate::new(200).set_body_bytes(zip_data.clone()))
1227
                .mount(&mock_server)
1228
                .await;
1229

1230
            let client = Client::new();
1231
            let result = download_backup(
1232
                &client,
1233
                &server_address,
1234
                task_id,
1235
                Some(&output_path.to_path_buf()),
1236
                None,
1237
                "zip",
1238
            )
1239
            .await;
1240

1241
            assert!(result.is_ok());
1242

1243
            // Verify the file was extracted
1244
            let extracted_file = output_path.join("test.txt");
1245
            assert!(extracted_file.exists());
1246
            let content = std::fs::read_to_string(&extracted_file).unwrap();
1247
            assert_eq!(content, "Hello, World!");
1248
        }
1249

1250
        #[tokio::test]
1251
        async fn downloads_and_extracts_tar_gz_archive() {
1252
            let mock_server = MockServer::start().await;
1253
            let server_address = mock_server.uri();
1254
            let task_id = "test-task-tar";
1255

1256
            // Create a temporary directory for extraction
1257
            let temp_dir = TempDir::new().unwrap();
1258
            let output_path = temp_dir.path();
1259

1260
            // Mock download token response
1261
            let token_response = serde_json::json!({
1262
                "token": "download-token-tar"
1263
            });
1264

1265
            Mock::given(method("POST"))
1266
                .and(path(format!("/v1/backups/{}/download-tokens", task_id)))
1267
                .respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
1268
                .mount(&mock_server)
1269
                .await;
1270

1271
            // Create a simple tar.gz file content
1272
            let mut tar_data = Vec::new();
1273
            {
1274
                use flate2::write::GzEncoder;
1275
                use flate2::Compression;
1276
                use tar::Builder;
1277

1278
                let gz = GzEncoder::new(&mut tar_data, Compression::default());
1279
                let mut tar = Builder::new(gz);
1280
                let mut header = tar::Header::new_gnu();
1281
                header.set_path("test.txt").unwrap();
1282
                header.set_size(13);
1283
                header.set_cksum();
1284
                tar.append(&header, &b"Hello, World!"[..]).unwrap();
1285
                tar.finish().unwrap();
1286
            }
1287

1288
            // Mock download response
1289
            Mock::given(method("GET"))
1290
                .and(path(format!("/v1/backups/{}/download", task_id)))
1291
                .and(query_param("token", "download-token-tar"))
1292
                .respond_with(ResponseTemplate::new(200).set_body_bytes(tar_data.clone()))
1293
                .mount(&mock_server)
1294
                .await;
1295

1296
            let client = Client::new();
1297
            let result = download_backup(
1298
                &client,
1299
                &server_address,
1300
                task_id,
1301
                Some(&output_path.to_path_buf()),
1302
                None,
1303
                "tar.gz",
1304
            )
1305
            .await;
1306

1307
            assert!(result.is_ok());
1308

1309
            // Verify the file was extracted
1310
            let extracted_file = output_path.join("test.txt");
1311
            assert!(extracted_file.exists());
1312
            let content = std::fs::read_to_string(&extracted_file).unwrap();
1313
            assert_eq!(content, "Hello, World!");
1314
        }
1315

1316
        #[tokio::test]
1317
        async fn handles_download_token_error() {
1318
            let mock_server = MockServer::start().await;
1319
            let server_address = mock_server.uri();
1320
            let task_id = "test-task-token-error";
1321

1322
            Mock::given(method("POST"))
1323
                .and(path(format!("/v1/backups/{}/download-tokens", task_id)))
1324
                .respond_with(ResponseTemplate::new(500).set_body_string("Token generation failed"))
1325
                .mount(&mock_server)
1326
                .await;
1327

1328
            let client = Client::new();
1329
            let result =
1330
                download_backup(&client, &server_address, task_id, None, None, "zip").await;
1331

1332
            assert!(result.is_err());
1333
            assert!(result
1334
                .unwrap_err()
1335
                .to_string()
1336
                .contains("Failed to get download token"));
1337
        }
1338

1339
        #[tokio::test]
1340
        async fn handles_download_error() {
1341
            let mock_server = MockServer::start().await;
1342
            let server_address = mock_server.uri();
1343
            let task_id = "test-task-download-error";
1344

1345
            // Mock download token response
1346
            let token_response = serde_json::json!({
1347
                "token": "download-token-error"
1348
            });
1349

1350
            Mock::given(method("POST"))
1351
                .and(path(format!("/v1/backups/{}/download-tokens", task_id)))
1352
                .respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
1353
                .mount(&mock_server)
1354
                .await;
1355

1356
            // Mock download error
1357
            Mock::given(method("GET"))
1358
                .and(path(format!("/v1/backups/{}/download", task_id)))
1359
                .respond_with(ResponseTemplate::new(500).set_body_string("Download failed"))
1360
                .mount(&mock_server)
1361
                .await;
1362

1363
            let client = Client::new();
1364
            let result =
1365
                download_backup(&client, &server_address, task_id, None, None, "zip").await;
1366

1367
            assert!(result.is_err());
1368
            assert!(result
1369
                .unwrap_err()
1370
                .to_string()
1371
                .contains("Failed to download archive"));
1372
        }
1373

1374
        #[tokio::test]
1375
        async fn handles_unknown_archive_format() {
1376
            let mock_server = MockServer::start().await;
1377
            let server_address = mock_server.uri();
1378
            let task_id = "test-task-unknown-format";
1379

1380
            // Mock download token response
1381
            let token_response = serde_json::json!({
1382
                "token": "download-token-unknown"
1383
            });
1384

1385
            Mock::given(method("POST"))
1386
                .and(path(format!("/v1/backups/{}/download-tokens", task_id)))
1387
                .respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
1388
                .mount(&mock_server)
1389
                .await;
1390

1391
            // Mock download response
1392
            Mock::given(method("GET"))
1393
                .and(path(format!("/v1/backups/{}/download", task_id)))
1394
                .respond_with(ResponseTemplate::new(200).set_body_bytes(b"some data"))
1395
                .mount(&mock_server)
1396
                .await;
1397

1398
            let client = Client::new();
1399
            let result =
1400
                download_backup(&client, &server_address, task_id, None, None, "unknown").await;
1401

1402
            assert!(result.is_err());
1403
            assert!(result
1404
                .unwrap_err()
1405
                .to_string()
1406
                .contains("Unknown archive format"));
1407
        }
1408
    }
1409

1410
    mod run_tests {
1411
        use super::*;
1412
        use std::fs;
1413

1414
        #[tokio::test]
1415
        async fn runs_successfully_with_valid_config() {
1416
            let mock_server = MockServer::start().await;
1417
            let server_address = mock_server.uri();
1418

1419
            // Create a temporary tokens config file
1420
            let temp_dir = TempDir::new().unwrap();
1421
            let tokens_config_path = temp_dir.path().join("tokens.toml");
1422
            fs::write(
1423
                &tokens_config_path,
1424
                r#"
1425
ethereum = ["0x123:1"]
1426
"#,
1427
            )
1428
            .unwrap();
1429

1430
            // Mock the backup creation
1431
            let backup_response = create_test_backup_create_response();
1432
            Mock::given(method("POST"))
1433
                .and(path("/v1/backups"))
1434
                .respond_with(ResponseTemplate::new(201).set_body_json(&backup_response))
1435
                .mount(&mock_server)
1436
                .await;
1437

1438
            // Mock the status check
1439
            let status_response = create_test_backup_response();
1440
            Mock::given(method("GET"))
1441
                .and(path("/v1/backups/test-task-123"))
1442
                .respond_with(ResponseTemplate::new(200).set_body_json(&status_response))
1443
                .mount(&mock_server)
1444
                .await;
1445

1446
            // Mock download token response
1447
            let token_response = serde_json::json!({
1448
                "token": "download-token-123"
1449
            });
1450
            Mock::given(method("POST"))
1451
                .and(path("/v1/backups/test-task-123/download-tokens"))
1452
                .respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
1453
                .mount(&mock_server)
1454
                .await;
1455

1456
            // Create a simple tar.gz file content (Linux user agent maps to tar.gz)
1457
            let mut tar_data = Vec::new();
1458
            {
1459
                use flate2::write::GzEncoder;
1460
                use flate2::Compression;
1461
                use tar::Builder;
1462

1463
                let gz = GzEncoder::new(&mut tar_data, Compression::default());
1464
                let mut tar = Builder::new(gz);
1465
                let mut header = tar::Header::new_gnu();
1466
                header.set_path("test.txt").unwrap();
1467
                header.set_size(13);
1468
                header.set_cksum();
1469
                tar.append(&header, &b"Hello, World!"[..]).unwrap();
1470
                tar.finish().unwrap();
1471
            }
1472

1473
            // Mock download response
1474
            Mock::given(method("GET"))
1475
                .and(path("/v1/backups/test-task-123/download"))
1476
                .respond_with(ResponseTemplate::new(200).set_body_bytes(tar_data.clone()))
1477
                .mount(&mock_server)
1478
                .await;
1479

1480
            let result = run(
1481
                tokens_config_path,
1482
                server_address,
1483
                Some(temp_dir.path().to_path_buf()),
1484
                false,
1485
                "Linux".to_string(),
1486
                false,
1487
                Some(10), // Very fast polling for tests
1488
            )
1489
            .await;
1490

1491
            if let Err(e) = &result {
1492
                println!("Test failed with error: {}", e);
1493
            }
1494
            assert!(result.is_ok());
1495
        }
1496
    }
1497
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc