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

0xmichalis / nftbk / 18659805528

20 Oct 2025 05:25PM UTC coverage: 37.563% (+1.5%) from 36.045%
18659805528

push

github

0xmichalis
chore: more log cleanup

0 of 2 new or added lines in 1 file covered. (0.0%)

129 existing lines in 4 files now uncovered.

1714 of 4563 relevant lines covered (37.56%)

7.05 hits per line

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

53.95
/src/server/handlers/handle_backup_create.rs
1
use axum::{
2
    extract::{Extension, Json, State},
3
    http::{HeaderMap, StatusCode},
4
    response::IntoResponse,
5
};
6
use serde_json;
7
use std::collections::HashSet;
8
use tracing::{debug, error, info};
9

10
use crate::server::api::{ApiProblem, BackupCreateResponse, BackupRequest, ProblemJson};
11
use crate::server::archive::negotiate_archive_format;
12
use crate::server::database::r#trait::Database;
13
use crate::server::hashing::compute_task_id;
14
use crate::server::{AppState, BackupTask, BackupTaskOrShutdown, StorageMode, TaskType};
15

16
fn get_status(meta: &crate::server::database::BackupTask, scope: &StorageMode) -> Option<String> {
7✔
17
    match scope {
7✔
18
        StorageMode::Archive => meta.archive_status.clone(),
8✔
19
        StorageMode::Ipfs => meta.ipfs_status.clone(),
6✔
20
        StorageMode::Full => {
21
            unreachable!("get_status reached with Full scope in POST /backups path")
22
        }
23
    }
24
}
25

26
/// Validate the scope against an existing task.
27
/// Invalidate Archive scope when the subresource is being deleted.
28
/// Invalidate Ipfs scope when the subresource is being deleted.
29
/// Full scope is invalid for existing tasks and clients should ensure to
30
/// specify the right scope explicitly.
31
/// Returns true if the requested scope is invalid for an existing task
32
pub(crate) fn validate_scope(
12✔
33
    scope: &StorageMode,
34
    meta: &crate::server::database::BackupTask,
35
) -> bool {
36
    match scope {
12✔
37
        StorageMode::Archive => meta.archive_deleted_at.is_some(),
6✔
38
        StorageMode::Ipfs => meta.pins_deleted_at.is_some(),
8✔
39
        StorageMode::Full => true,
2✔
40
    }
41
}
42

43
fn validate_backup_request(state: &AppState, req: &BackupRequest) -> Result<(), String> {
2✔
44
    validate_backup_request_impl(&state.chain_config, state.ipfs_pinning_configs.len(), req)
10✔
45
}
46

47
fn validate_backup_request_impl(
7✔
48
    chain_config: &crate::backup::ChainConfig,
49
    ipfs_pinning_configs_len: usize,
50
    req: &BackupRequest,
51
) -> Result<(), String> {
52
    // Validate requested chains
53
    let configured_chains: HashSet<_> = chain_config.0.keys().cloned().collect();
42✔
54
    let mut unknown_chains = Vec::new();
14✔
55
    for entry in &req.tokens {
21✔
56
        if !configured_chains.contains(&entry.chain) {
2✔
57
            unknown_chains.push(entry.chain.clone());
2✔
58
        }
59
    }
60
    if !unknown_chains.is_empty() {
7✔
61
        let msg = format!("Unknown chains requested: {}", unknown_chains.join(", "));
2✔
62
        return Err(msg);
63
    }
64
    // Validate requested operation is meaningful
65
    if !req.create_archive && !req.pin_on_ipfs {
6✔
66
        return Err("Either create_archive must be true or pin_on_ipfs must be true".to_string());
1✔
67
    }
68
    // Validate IPFS pinning configuration
69
    if req.pin_on_ipfs && ipfs_pinning_configs_len == 0 {
7✔
70
        return Err("pin_on_ipfs requested but no IPFS pinning providers configured".to_string());
2✔
71
    }
72
    Ok(())
73
}
74

75
#[allow(clippy::too_many_arguments)]
76
async fn ensure_backup_exists<DB: Database + ?Sized>(
13✔
77
    db: &DB,
78
    existing: Option<&crate::server::database::BackupTask>,
79
    task_id: &str,
80
    requestor: &str,
81
    tokens: &Vec<crate::server::api::Tokens>,
82
    storage_mode: &StorageMode,
83
    archive_format_opt: &Option<String>,
84
    pruner_retention_days: u64,
85
) -> Result<(), sqlx::Error> {
86
    // If backup exists, upgrade it to full and ensure missing subresource exists
87
    if let Some(_existing) = existing {
17✔
88
        db.upgrade_backup_to_full(
×
89
            task_id,
×
90
            *storage_mode == StorageMode::Archive,
×
91
            archive_format_opt.as_deref(),
×
92
            Some(pruner_retention_days),
×
93
        )
94
        .await?;
1✔
95
        return Ok(());
3✔
96
    }
97

98
    // Otherwise, create a new backup
99
    let nft_count = tokens.iter().map(|t| t.tokens.len()).sum::<usize>() as i32;
54✔
100
    let tokens_json = serde_json::to_value(tokens).unwrap();
36✔
101
    db.insert_backup_task(
27✔
102
        task_id,
18✔
103
        requestor,
18✔
104
        nft_count,
18✔
105
        &tokens_json,
18✔
106
        storage_mode.as_str(),
18✔
107
        archive_format_opt.as_deref(),
27✔
108
        Some(pruner_retention_days),
9✔
109
    )
110
    .await?;
10✔
111
    Ok(())
8✔
112
}
113

114
/// Create a backup task for the authenticated user. This task will be processed asynchronously and the result will be available in the /v1/backups/{task_id} endpoint.
115
/// By default, this endpoint creates an archive file with metadata and content from all tokens to be downloaded by the user. The archive format depends on the user-agent (zip or tar.gz).
116
/// The user can optionally request to pin content that is stored on IPFS. If configured in the server, a payment can be required to create the backup task.
117
#[utoipa::path(
118
    post,
119
    path = "/v1/backups",
120
    request_body = BackupRequest,
121
    params(
122
        ("accept" = Option<String>, Header, description = "Preferred archive media type for backup content: application/zip or application/gzip. If omitted or undecidable, defaults to zip."),
123
        ("user-agent" = Option<String>, Header, description = "Used as a heuristic fallback to select archive format when Accept is not provided.")
124
    ),
125
    responses(
126
        (status = 202, description = "Backup task accepted and queued", body = BackupCreateResponse),
127
        (status = 200, description = "Backup already exists or in progress", body = BackupCreateResponse),
128
        (status = 400, description = "Invalid request", body = ApiProblem, content_type = "application/problem+json"),
129
        (status = 402, description = "Payment required. See https://www.x402.org/ for more details.", body = ApiProblem, content_type = "application/problem+json"),
130
        (status = 403, description = "Requestor does not match task owner", body = ApiProblem, content_type = "application/problem+json"),
131
        (status = 409, description = "Invalid scope for existing task", body = ApiProblem, content_type = "application/problem+json"),
132
        (status = 500, description = "Internal server error", body = ApiProblem, content_type = "application/problem+json"),
133
    ),
134
    tag = "backups",
135
    security(("bearer_auth" = []))
136
)]
137
pub async fn handle_backup_create(
2✔
138
    State(state): State<AppState>,
139
    Extension(requestor): Extension<Option<String>>,
140
    headers: HeaderMap,
141
    Json(req): Json<BackupRequest>,
142
) -> axum::response::Response {
143
    if let Err(msg) = validate_backup_request(&state, &req) {
6✔
144
        let problem = ProblemJson::from_status(
145
            StatusCode::BAD_REQUEST,
146
            Some(msg),
147
            Some("/v1/backups".to_string()),
148
        );
149
        return problem.into_response();
150
    }
151
    let response = handle_backup_create_core(
152
        &*state.db,
×
153
        &state.backup_task_sender,
×
154
        requestor,
×
155
        &headers,
×
156
        req,
×
UNCOV
157
        state.pruner_retention_days,
×
158
    )
159
    .await;
×
UNCOV
160
    response
×
161
}
162

163
async fn handle_backup_create_core<DB: Database + ?Sized>(
13✔
164
    db: &DB,
165
    backup_task_sender: &tokio::sync::mpsc::Sender<BackupTaskOrShutdown>,
166
    requestor: Option<String>,
167
    headers: &HeaderMap,
168
    req: BackupRequest,
169
    pruner_retention_days: u64,
170
) -> axum::response::Response {
171
    // Validate that requestor is present
172
    let requestor_str = match &requestor {
24✔
173
        Some(s) if !s.is_empty() => s.clone(),
36✔
UNCOV
174
        _ => {
×
175
            let problem = ProblemJson::from_status(
176
                StatusCode::BAD_REQUEST,
×
177
                Some("Requestor required".to_string()),
×
UNCOV
178
                Some("/v1/backups".to_string()),
×
179
            );
UNCOV
180
            return problem.into_response();
×
181
        }
182
    };
183

184
    // Determine storage mode
185
    let storage_mode = match (req.create_archive, req.pin_on_ipfs) {
12✔
UNCOV
186
        (true, true) => StorageMode::Full,
×
187
        (true, false) => StorageMode::Archive,
10✔
188
        (false, true) => StorageMode::Ipfs,
2✔
UNCOV
189
        (false, false) => {
×
190
            // Should be prevented by validation; return error defensively
191
            let problem = ProblemJson::from_status(
192
                StatusCode::BAD_REQUEST,
×
193
                Some("Either create_archive must be true or pin_on_ipfs must be true".to_string()),
×
UNCOV
194
                Some("/v1/backups".to_string()),
×
195
            );
UNCOV
196
            return problem.into_response();
×
197
        }
198
    };
199

200
    // Determine task id
UNCOV
201
    let task_id = compute_task_id(&req.tokens, Some(&requestor_str));
×
202

203
    // Validate against existing tasks
204
    let existing_meta: Option<crate::server::database::BackupTask> =
24✔
205
        db.get_backup_task(&task_id).await.unwrap_or_default();
12✔
206
    if let Some(task_meta) = existing_meta.clone() {
17✔
207
        // Ensure the requestor matches the existing task owner
208
        if !task_meta.requestor.is_empty() && task_meta.requestor != requestor_str {
5✔
209
            let problem = ProblemJson::from_status(
UNCOV
210
                StatusCode::FORBIDDEN,
×
211
                Some("Requestor does not match task owner".to_string()),
1✔
212
                Some(format!("/v1/backups/{task_id}")),
1✔
213
            );
214
            return problem.into_response();
2✔
215
        }
216

UNCOV
217
        if validate_scope(&storage_mode, &task_meta) {
×
218
            let problem = ProblemJson::from_status(
UNCOV
219
                StatusCode::CONFLICT,
×
220
                Some("Invalid scope for existing task".to_string()),
1✔
221
                Some(format!("/v1/backups/{task_id}")),
1✔
222
            );
223
            return problem.into_response();
2✔
224
        }
225

226
        if let Some(status) = get_status(&task_meta, &storage_mode) {
1✔
227
            match status.as_str() {
×
UNCOV
228
                "in_progress" => {
×
229
                    debug!("Duplicate backup request, returning existing task_id {task_id}");
1✔
230
                    return (StatusCode::OK, Json(BackupCreateResponse { task_id }))
1✔
231
                        .into_response();
1✔
232
                }
233
                "done" => {
×
234
                    debug!("Backup already completed, returning existing task_id {task_id}");
×
235
                    return (StatusCode::OK, Json(BackupCreateResponse { task_id }))
×
UNCOV
236
                        .into_response();
×
237
                }
UNCOV
238
                "error" | "expired" => {
×
239
                    let problem = ProblemJson::from_status(
240
                        StatusCode::CONFLICT,
×
241
                        Some(format!(
×
UNCOV
242
                            "Backup in status {status} cannot be started. Use retry.",
×
243
                        )),
UNCOV
244
                        Some(format!("/v1/backups/{task_id}")),
×
245
                    );
UNCOV
246
                    return problem.into_response();
×
247
                }
248
                other => {
×
249
                    error!(
×
UNCOV
250
                        "Unknown backup status '{other}' for task {task_id} when handling /backup"
×
251
                    );
252
                    let problem = ProblemJson::from_status(
253
                        StatusCode::INTERNAL_SERVER_ERROR,
×
254
                        Some("Unknown backup status".to_string()),
×
UNCOV
255
                        Some("/v1/backups".to_string()),
×
256
                    );
UNCOV
257
                    return problem.into_response();
×
258
                }
259
            }
260
        }
261
    }
262

263
    // Determine archive format if necessary
264
    let archive_format_opt: Option<String> = if storage_mode == StorageMode::Ipfs {
9✔
265
        None
2✔
266
    } else {
267
        Some(negotiate_archive_format(
7✔
268
            headers.get("accept").and_then(|v| v.to_str().ok()),
12✔
269
            headers.get("user-agent").and_then(|v| v.to_str().ok()),
6✔
270
        ))
271
    };
272

273
    // Ensure backup exists in the database
274
    if let Err(e) = ensure_backup_exists(
275
        db,
×
276
        existing_meta.as_ref(),
×
277
        &task_id,
×
278
        &requestor_str,
×
279
        &req.tokens,
×
280
        &storage_mode,
×
281
        &archive_format_opt,
×
UNCOV
282
        pruner_retention_days,
×
283
    )
UNCOV
284
    .await
×
285
    {
286
        let problem = ProblemJson::from_status(
287
            StatusCode::INTERNAL_SERVER_ERROR,
×
288
            Some(format!("Failed to write metadata to DB: {e}")),
×
UNCOV
289
            Some(format!("/v1/backups/{}", task_id)),
×
290
        );
UNCOV
291
        return problem.into_response();
×
292
    }
293

294
    // Enqueue backup task
295
    let backup_task = BackupTask {
296
        task_id: task_id.clone(),
27✔
297
        request: req.clone(),
27✔
298
        scope: storage_mode.clone(),
27✔
299
        archive_format: archive_format_opt.clone(),
27✔
300
        requestor: Some(requestor_str.clone()),
9✔
301
    };
302
    if let Err(e) = backup_task_sender
9✔
303
        .send(BackupTaskOrShutdown::Task(TaskType::Creation(backup_task)))
18✔
304
        .await
9✔
305
    {
UNCOV
306
        error!("Failed to enqueue backup task: {e}");
×
307
        let problem = ProblemJson::from_status(
308
            StatusCode::INTERNAL_SERVER_ERROR,
×
309
            Some("Failed to enqueue backup task".to_string()),
×
UNCOV
310
            Some(format!("/v1/backups/{}", task_id)),
×
311
        );
UNCOV
312
        return problem.into_response();
×
313
    }
314

315
    info!(
9✔
316
        "Created backup task {} (requestor: {}, count: {}, storage_mode: {})",
×
317
        task_id,
×
318
        requestor.unwrap_or_default(),
×
319
        req.tokens.iter().map(|t| t.tokens.len()).sum::<usize>(),
×
UNCOV
320
        storage_mode.as_str()
×
321
    );
322
    let mut headers = axum::http::HeaderMap::new();
18✔
323
    headers.insert(
18✔
324
        axum::http::header::LOCATION,
9✔
325
        format!("/v1/backups/{task_id}").parse().unwrap(),
27✔
326
    );
327
    (
328
        StatusCode::ACCEPTED,
9✔
329
        headers,
9✔
330
        Json(BackupCreateResponse { task_id }),
9✔
331
    )
332
        .into_response()
333
}
334

335
#[cfg(test)]
336
mod ensure_backup_exists_unit_tests {
337
    use super::ensure_backup_exists;
338
    use crate::server::api::Tokens;
339
    use crate::server::database::r#trait::MockDatabase;
340
    use crate::server::database::BackupTask;
341
    use crate::server::StorageMode;
342

343
    fn sample_meta() -> BackupTask {
344
        BackupTask {
345
            task_id: "t".to_string(),
346
            created_at: chrono::Utc::now(),
347
            updated_at: chrono::Utc::now(),
348
            requestor: "did:test".to_string(),
349
            nft_count: 1,
350
            tokens: serde_json::json!([{"chain":"ethereum","tokens":["0xabc:1"]}]),
351
            archive_status: Some("done".to_string()),
352
            ipfs_status: None,
353
            archive_error_log: None,
354
            ipfs_error_log: None,
355
            archive_fatal_error: None,
356
            ipfs_fatal_error: None,
357
            storage_mode: "archive".to_string(),
358
            archive_format: Some("zip".to_string()),
359
            expires_at: None,
360
            archive_deleted_at: None,
361
            pins_deleted_at: None,
362
        }
363
    }
364

365
    #[tokio::test]
366
    async fn existing_meta_upgrades_ok() {
367
        let db = MockDatabase::default();
368
        let meta = sample_meta();
369
        let tokens = vec![Tokens {
370
            chain: "ethereum".to_string(),
371
            tokens: vec!["0xabc:1".to_string()],
372
        }];
373
        let res = ensure_backup_exists(
374
            &db,
375
            Some(&meta),
376
            "t",
377
            "did:test",
378
            &tokens,
379
            &StorageMode::Archive,
380
            &None,
381
            7,
382
        )
383
        .await;
384
        assert!(res.is_ok());
385
    }
386

387
    #[tokio::test]
388
    async fn new_insert_ok() {
389
        let db = MockDatabase::default();
390
        let tokens = vec![Tokens {
391
            chain: "ethereum".to_string(),
392
            tokens: vec!["0xabc:1".to_string()],
393
        }];
394
        let res = ensure_backup_exists(
395
            &db,
396
            None,
397
            "t",
398
            "did:test",
399
            &tokens,
400
            &StorageMode::Archive,
401
            &Some("zip".to_string()),
402
            7,
403
        )
404
        .await;
405
        assert!(res.is_ok());
406
    }
407

408
    #[tokio::test]
409
    async fn insert_error_propagates() {
410
        let mut db = MockDatabase::default();
411
        db.set_insert_backup_task_error(Some("boom".to_string()));
412
        let tokens = vec![Tokens {
413
            chain: "ethereum".to_string(),
414
            tokens: vec!["0xabc:1".to_string()],
415
        }];
416
        let res = ensure_backup_exists(
417
            &db,
418
            None,
419
            "t",
420
            "did:test",
421
            &tokens,
422
            &StorageMode::Archive,
423
            &Some("zip".to_string()),
424
            7,
425
        )
426
        .await;
427
        assert!(res.is_err());
428
    }
429

430
    #[tokio::test]
431
    async fn upgrade_error_propagates() {
432
        let mut db = MockDatabase::default();
433
        db.set_upgrade_backup_to_full_error(Some("boom".to_string()));
434
        let meta = sample_meta();
435
        let tokens = vec![Tokens {
436
            chain: "ethereum".to_string(),
437
            tokens: vec!["0xabc:1".to_string()],
438
        }];
439
        let res = ensure_backup_exists(
440
            &db,
441
            Some(&meta),
442
            "t",
443
            "did:test",
444
            &tokens,
445
            &StorageMode::Archive,
446
            &Some("zip".to_string()),
447
            7,
448
        )
449
        .await;
450
        assert!(res.is_err());
451
    }
452
}
453

454
#[cfg(test)]
455
mod validate_backup_request_impl_tests {
456
    use super::validate_backup_request_impl;
457
    use crate::backup::ChainConfig;
458
    use crate::server::api::{BackupRequest, Tokens};
459

460
    fn make_chain_config(chains: &[&str]) -> ChainConfig {
461
        let mut map = std::collections::HashMap::new();
462
        for &c in chains {
463
            map.insert(c.to_string(), "rpc://dummy".to_string());
464
        }
465
        ChainConfig(map)
466
    }
467

468
    #[test]
469
    fn rejects_unknown_chains() {
470
        let chain_config = make_chain_config(&["ethereum", "tezos"]);
471
        let req = BackupRequest {
472
            tokens: vec![Tokens {
473
                chain: "polygon".to_string(),
474
                tokens: vec!["0xabc:1".to_string()],
475
            }],
476
            pin_on_ipfs: false,
477
            create_archive: true,
478
        };
479
        let result = validate_backup_request_impl(&chain_config, 1, &req);
480
        assert!(result.is_err());
481
        let err = result.err().unwrap();
482
        assert!(err.contains("Unknown chains requested"));
483
    }
484

485
    #[test]
486
    fn rejects_pin_without_ipfs_pinning_configs() {
487
        let chain_config = make_chain_config(&["ethereum"]);
488
        let req = BackupRequest {
489
            tokens: vec![Tokens {
490
                chain: "ethereum".to_string(),
491
                tokens: vec!["0xabc:1".to_string()],
492
            }],
493
            pin_on_ipfs: true,
494
            create_archive: true,
495
        };
496
        let result = validate_backup_request_impl(&chain_config, 0, &req);
497
        assert!(result.is_err());
498
        assert_eq!(
499
            result.err().unwrap(),
500
            "pin_on_ipfs requested but no IPFS pinning providers configured"
501
        );
502
    }
503

504
    #[test]
505
    fn accepts_valid_request_with_ipfs() {
506
        let chain_config = make_chain_config(&["ethereum"]);
507
        let req = BackupRequest {
508
            tokens: vec![Tokens {
509
                chain: "ethereum".to_string(),
510
                tokens: vec!["0xabc:1".to_string()],
511
            }],
512
            pin_on_ipfs: true,
513
            create_archive: true,
514
        };
515
        let result = validate_backup_request_impl(&chain_config, 2, &req);
516
        assert!(result.is_ok());
517
    }
518

519
    #[test]
520
    fn accepts_valid_request_without_ipfs() {
521
        let chain_config = make_chain_config(&["tezos"]);
522
        let req = BackupRequest {
523
            tokens: vec![Tokens {
524
                chain: "tezos".to_string(),
525
                tokens: vec!["KT1abc:1".to_string()],
526
            }],
527
            pin_on_ipfs: false,
528
            create_archive: true,
529
        };
530
        let result = validate_backup_request_impl(&chain_config, 0, &req);
531
        assert!(result.is_ok());
532
    }
533

534
    #[test]
535
    fn rejects_when_both_create_archive_and_pin_false() {
536
        let chain_config = make_chain_config(&["ethereum"]);
537
        let req = BackupRequest {
538
            tokens: vec![Tokens {
539
                chain: "ethereum".to_string(),
540
                tokens: vec!["0xabc:1".to_string()],
541
            }],
542
            pin_on_ipfs: false,
543
            create_archive: false,
544
        };
545
        let result = validate_backup_request_impl(&chain_config, 1, &req);
546
        assert!(result.is_err());
547
        let err = result.err().unwrap();
548
        assert!(err.contains("Either create_archive must be true or pin_on_ipfs must be true"));
549
    }
550
}
551

552
#[cfg(test)]
553
mod handle_backup_endpoint_tests {
554
    use super::handle_backup_create as handle_backup;
555
    use axum::http::StatusCode;
556
    use axum::{routing::post, Extension, Router};
557
    use hyper::Request;
558
    use serde_json::json;
559
    use std::collections::HashMap;
560
    use std::sync::Arc;
561
    use tokio::sync::{mpsc, Mutex};
562
    use tower::Service;
563

564
    use crate::ipfs::IpfsPinningConfig;
565
    use crate::server::database::Db;
566
    use crate::server::AppState;
567

568
    fn make_state(ipfs_pinning_configs: Vec<IpfsPinningConfig>) -> AppState {
569
        let mut chains = HashMap::new();
570
        chains.insert("ethereum".to_string(), "rpc://dummy".to_string());
571
        let chain_config = crate::backup::ChainConfig(chains);
572
        let (tx, _rx) = mpsc::channel(1);
573
        let pool = sqlx::postgres::PgPoolOptions::new()
574
            .connect_lazy("postgres://user:pass@localhost/db")
575
            .unwrap();
576
        let db = Arc::new(Db { pool });
577
        AppState {
578
            chain_config: Arc::new(chain_config),
579
            base_dir: Arc::new("/tmp".to_string()),
580
            unsafe_skip_checksum_check: true,
581
            auth_token: None,
582
            pruner_enabled: false,
583
            pruner_retention_days: 7,
584
            download_tokens: Arc::new(Mutex::new(HashMap::new())),
585
            backup_task_sender: tx,
586
            db,
587
            shutdown_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
588
            ipfs_pinning_configs,
589
            ipfs_pinning_instances: Arc::new(Vec::new()),
590
        }
591
    }
592

593
    #[tokio::test]
594
    async fn returns_400_for_unknown_chain() {
595
        let state = make_state(Vec::new());
596
        let app = Router::new()
597
            .route("/backup", post(handle_backup))
598
            .with_state(state)
599
            .layer(Extension::<Option<String>>(None));
600

601
        let req_body = json!({
602
            "tokens": [ { "chain": "polygon", "tokens": ["0xabc:1"] } ]
603
        });
604
        let request = Request::builder()
605
            .method("POST")
606
            .uri("/backup")
607
            .header("content-type", "application/json")
608
            .header("user-agent", "Linux")
609
            .body(axum::body::Body::from(req_body.to_string()))
610
            .unwrap();
611

612
        let mut app = app;
613
        let response = app.call(request).await.unwrap();
614
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
615
    }
616

617
    #[tokio::test]
618
    async fn returns_400_for_pin_without_ipfs_pinning_configs() {
619
        let state = make_state(Vec::new());
620
        let app = Router::new()
621
            .route("/backup", post(handle_backup))
622
            .with_state(state)
623
            .layer(Extension::<Option<String>>(None));
624

625
        let req = crate::server::api::BackupRequest {
626
            tokens: vec![crate::server::api::Tokens {
627
                chain: "ethereum".to_string(),
628
                tokens: vec!["0xabc:1".to_string()],
629
            }],
630
            pin_on_ipfs: true,
631
            create_archive: true,
632
        };
633
        let request = Request::builder()
634
            .method("POST")
635
            .uri("/backup")
636
            .header("content-type", "application/json")
637
            .header("user-agent", "Linux")
638
            .body(axum::body::Body::from(serde_json::to_string(&req).unwrap()))
639
            .unwrap();
640

641
        let mut app = app;
642
        let response = app.call(request).await.unwrap();
643
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
644
    }
645
}
646

647
#[cfg(test)]
648
mod handle_backup_core_tests {
649
    use crate::server::api::{BackupRequest, Tokens};
650
    use crate::server::database::r#trait::MockDatabase;
651
    use axum::body::to_bytes;
652
    use axum::http::{HeaderMap, StatusCode};
653
    use axum::response::IntoResponse;
654
    use tokio::sync::mpsc;
655

656
    #[tokio::test]
657
    async fn returns_400_when_missing_requestor() {
658
        let db = MockDatabase::default();
659
        let (tx, _rx) = tokio::sync::mpsc::channel(1);
660
        let req = BackupRequest {
661
            tokens: vec![Tokens {
662
                chain: "ethereum".to_string(),
663
                tokens: vec!["0xabc:1".to_string()],
664
            }],
665
            pin_on_ipfs: false,
666
            create_archive: true,
667
        };
668
        let headers = HeaderMap::new();
669
        let resp = super::handle_backup_create_core(&db, &tx, None, &headers, req, 7)
670
            .await
671
            .into_response();
672
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
673
    }
674

675
    #[tokio::test]
676
    async fn returns_202_and_enqueues_on_new_task() {
677
        let db = MockDatabase::default();
678
        let (tx, mut rx) = mpsc::channel(1);
679
        let req = BackupRequest {
680
            tokens: vec![Tokens {
681
                chain: "ethereum".to_string(),
682
                tokens: vec!["0xabc:1".to_string()],
683
            }],
684
            pin_on_ipfs: false,
685
            create_archive: true,
686
        };
687
        let headers = HeaderMap::new();
688
        let resp = super::handle_backup_create_core(
689
            &db,
690
            &tx,
691
            Some("did:test".to_string()),
692
            &headers,
693
            req,
694
            7,
695
        )
696
        .await
697
        .into_response();
698
        assert_eq!(resp.status(), StatusCode::ACCEPTED);
699
        // Assert Location header points to the created backup resource
700
        let location = resp
701
            .headers()
702
            .get(axum::http::header::LOCATION)
703
            .unwrap()
704
            .to_str()
705
            .unwrap()
706
            .to_string();
707
        let body_bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
708
        let v: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
709
        let task_id = v.get("task_id").and_then(|t| t.as_str()).unwrap();
710
        assert_eq!(location, format!("/v1/backups/{task_id}"));
711
        // Ensure task enqueued
712
        let task = rx.try_recv();
713
        assert!(task.is_ok());
714
    }
715

716
    #[tokio::test]
717
    async fn returns_409_when_being_deleted() {
718
        let mut db = MockDatabase::default();
719
        db.set_get_backup_task_result(Some(crate::server::database::BackupTask {
720
            task_id: "test".to_string(),
721
            created_at: chrono::Utc::now(),
722
            updated_at: chrono::Utc::now(),
723
            requestor: "did:test".to_string(),
724
            nft_count: 0,
725
            tokens: serde_json::Value::Null,
726
            archive_status: Some("done".to_string()),
727
            ipfs_status: None,
728
            archive_error_log: None,
729
            ipfs_error_log: None,
730
            archive_fatal_error: None,
731
            ipfs_fatal_error: None,
732
            storage_mode: "archive".to_string(),
733
            archive_format: None,
734
            expires_at: None,
735
            archive_deleted_at: Some(chrono::Utc::now()),
736
            pins_deleted_at: None,
737
        }));
738
        let (tx, _rx) = mpsc::channel(1);
739
        let req = BackupRequest {
740
            tokens: vec![Tokens {
741
                chain: "ethereum".to_string(),
742
                tokens: vec!["0xabc:1".to_string()],
743
            }],
744
            pin_on_ipfs: false,
745
            create_archive: true,
746
        };
747
        let headers = HeaderMap::new();
748
        let resp = super::handle_backup_create_core(
749
            &db,
750
            &tx,
751
            Some("did:test".to_string()),
752
            &headers,
753
            req,
754
            7,
755
        )
756
        .await
757
        .into_response();
758
        assert_eq!(resp.status(), StatusCode::CONFLICT);
759
    }
760

761
    #[tokio::test]
762
    async fn returns_403_when_existing_task_owner_mismatch() {
763
        let mut db = MockDatabase::default();
764
        db.set_get_backup_task_result(Some(crate::server::database::BackupTask {
765
            task_id: "towner".to_string(),
766
            created_at: chrono::Utc::now(),
767
            updated_at: chrono::Utc::now(),
768
            requestor: "did:privy:bob".to_string(),
769
            nft_count: 1,
770
            tokens: serde_json::json!([]),
771
            archive_status: Some("done".to_string()),
772
            ipfs_status: None,
773
            archive_error_log: None,
774
            ipfs_error_log: None,
775
            archive_fatal_error: None,
776
            ipfs_fatal_error: None,
777
            storage_mode: "archive".to_string(),
778
            archive_format: Some("zip".to_string()),
779
            expires_at: None,
780
            archive_deleted_at: None,
781
            pins_deleted_at: None,
782
        }));
783
        let (tx, _rx) = mpsc::channel(1);
784
        let req = BackupRequest {
785
            tokens: vec![Tokens {
786
                chain: "ethereum".to_string(),
787
                tokens: vec!["0xabc:1".to_string()],
788
            }],
789
            pin_on_ipfs: false,
790
            create_archive: true,
791
        };
792
        let headers = HeaderMap::new();
793
        let resp = super::handle_backup_create_core(
794
            &db,
795
            &tx,
796
            Some("did:privy:alice".to_string()),
797
            &headers,
798
            req,
799
            7,
800
        )
801
        .await
802
        .into_response();
803
        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
804
    }
805

806
    #[tokio::test]
807
    async fn returns_200_when_in_progress() {
808
        let mut db = MockDatabase::default();
809
        db.set_get_backup_task_result(Some(crate::server::database::BackupTask {
810
            task_id: "test".to_string(),
811
            created_at: chrono::Utc::now(),
812
            updated_at: chrono::Utc::now(),
813
            requestor: "did:test".to_string(),
814
            nft_count: 1,
815
            tokens: serde_json::json!([]),
816
            archive_status: Some("in_progress".to_string()),
817
            ipfs_status: None,
818
            archive_error_log: None,
819
            ipfs_error_log: None,
820
            archive_fatal_error: None,
821
            ipfs_fatal_error: None,
822
            storage_mode: "archive".to_string(),
823
            archive_format: Some("zip".to_string()),
824
            expires_at: None,
825
            archive_deleted_at: None,
826
            pins_deleted_at: None,
827
        }));
828
        let (tx, _rx) = mpsc::channel(1);
829
        let req = BackupRequest {
830
            tokens: vec![Tokens {
831
                chain: "ethereum".to_string(),
832
                tokens: vec!["0xabc:1".to_string()],
833
            }],
834
            pin_on_ipfs: false,
835
            create_archive: true,
836
        };
837
        let headers = HeaderMap::new();
838
        let resp = super::handle_backup_create_core(
839
            &db,
840
            &tx,
841
            Some("did:test".to_string()),
842
            &headers,
843
            req,
844
            7,
845
        )
846
        .await
847
        .into_response();
848
        assert_eq!(resp.status(), StatusCode::OK);
849
    }
850

851
    #[tokio::test]
852
    async fn accept_zip_selects_zip() {
853
        let db = MockDatabase::default();
854
        let (tx, mut rx) = mpsc::channel(1);
855
        let req = BackupRequest {
856
            tokens: vec![Tokens {
857
                chain: "ethereum".to_string(),
858
                tokens: vec!["0xabc:1".to_string()],
859
            }],
860
            pin_on_ipfs: false,
861
            create_archive: true,
862
        };
863
        let mut headers = HeaderMap::new();
864
        headers.insert("accept", "application/zip".parse().unwrap());
865
        let _ = super::handle_backup_create_core(
866
            &db,
867
            &tx,
868
            Some("did:test".to_string()),
869
            &headers,
870
            req,
871
            7,
872
        )
873
        .await
874
        .into_response();
875

876
        // Check that the backup task was sent with the correct archive format
877
        let task = rx.try_recv().unwrap();
878
        match task {
879
            crate::server::BackupTaskOrShutdown::Task(crate::server::TaskType::Creation(
880
                backup_task,
881
            )) => {
882
                assert_eq!(backup_task.archive_format, Some("zip".to_string()));
883
            }
884
            _ => panic!("Expected Creation task"),
885
        }
886
    }
887

888
    #[tokio::test]
889
    async fn accept_gzip_selects_tar_gz() {
890
        let db = MockDatabase::default();
891
        let (tx, mut rx) = mpsc::channel(1);
892
        let req = BackupRequest {
893
            tokens: vec![Tokens {
894
                chain: "ethereum".to_string(),
895
                tokens: vec!["0xabc:1".to_string()],
896
            }],
897
            pin_on_ipfs: false,
898
            create_archive: true,
899
        };
900
        let mut headers = HeaderMap::new();
901
        headers.insert("accept", "application/gzip".parse().unwrap());
902
        let _ = super::handle_backup_create_core(
903
            &db,
904
            &tx,
905
            Some("did:test".to_string()),
906
            &headers,
907
            req,
908
            7,
909
        )
910
        .await
911
        .into_response();
912

913
        // Check that the backup task was sent with the correct archive format
914
        let task = rx.try_recv().unwrap();
915
        match task {
916
            crate::server::BackupTaskOrShutdown::Task(crate::server::TaskType::Creation(
917
                backup_task,
918
            )) => {
919
                assert_eq!(backup_task.archive_format, Some("tar.gz".to_string()));
920
            }
921
            _ => panic!("Expected Creation task"),
922
        }
923
    }
924

925
    #[tokio::test]
926
    async fn undecidable_accept_defaults_zip_in_core() {
927
        let db = MockDatabase::default();
928
        let (tx, mut rx) = mpsc::channel(1);
929
        let req = BackupRequest {
930
            tokens: vec![Tokens {
931
                chain: "ethereum".to_string(),
932
                tokens: vec!["0xabc:1".to_string()],
933
            }],
934
            pin_on_ipfs: false,
935
            create_archive: true,
936
        };
937
        let mut headers = HeaderMap::new();
938
        headers.insert("accept", "application/json".parse().unwrap());
939
        let _ = super::handle_backup_create_core(
940
            &db,
941
            &tx,
942
            Some("did:test".to_string()),
943
            &headers,
944
            req,
945
            7,
946
        )
947
        .await
948
        .into_response();
949

950
        // Check that the backup task was sent with the default zip format
951
        let task = rx.try_recv().unwrap();
952
        match task {
953
            crate::server::BackupTaskOrShutdown::Task(crate::server::TaskType::Creation(
954
                backup_task,
955
            )) => {
956
                assert_eq!(backup_task.archive_format, Some("zip".to_string()));
957
            }
958
            _ => panic!("Expected Creation task"),
959
        }
960
    }
961

962
    #[tokio::test]
963
    async fn selects_from_user_agent() {
964
        let db = MockDatabase::default();
965
        let (tx, mut rx) = mpsc::channel(1);
966
        let req = BackupRequest {
967
            tokens: vec![Tokens {
968
                chain: "ethereum".to_string(),
969
                tokens: vec!["0xabc:1".to_string()],
970
            }],
971
            pin_on_ipfs: false,
972
            create_archive: true,
973
        };
974
        let mut headers = HeaderMap::new();
975
        headers.insert("user-agent", "Linux".parse().unwrap());
976
        let _ = super::handle_backup_create_core(
977
            &db,
978
            &tx,
979
            Some("did:test".to_string()),
980
            &headers,
981
            req,
982
            7,
983
        )
984
        .await
985
        .into_response();
986

987
        // Check that the backup task was sent with tar.gz format based on user agent
988
        let task = rx.try_recv().unwrap();
989
        match task {
990
            crate::server::BackupTaskOrShutdown::Task(crate::server::TaskType::Creation(
991
                backup_task,
992
            )) => {
993
                assert_eq!(backup_task.archive_format, Some("tar.gz".to_string()));
994
            }
995
            _ => panic!("Expected Creation task"),
996
        }
997
    }
998

999
    #[tokio::test]
1000
    async fn both_accept_and_user_agent_present_accept_wins() {
1001
        let db = MockDatabase::default();
1002
        let (tx, mut rx) = mpsc::channel(1);
1003
        let req = BackupRequest {
1004
            tokens: vec![Tokens {
1005
                chain: "ethereum".to_string(),
1006
                tokens: vec!["0xabc:1".to_string()],
1007
            }],
1008
            pin_on_ipfs: false,
1009
            create_archive: true,
1010
        };
1011
        let mut headers = HeaderMap::new();
1012
        headers.insert("user-agent", "Linux TarPreferred".parse().unwrap());
1013
        headers.insert("accept", "application/zip".parse().unwrap());
1014
        let _ = super::handle_backup_create_core(
1015
            &db,
1016
            &tx,
1017
            Some("did:test".to_string()),
1018
            &headers,
1019
            req,
1020
            7,
1021
        )
1022
        .await
1023
        .into_response();
1024

1025
        // Check that the backup task was sent with zip format (accept header wins over user agent)
1026
        let task = rx.try_recv().unwrap();
1027
        match task {
1028
            crate::server::BackupTaskOrShutdown::Task(crate::server::TaskType::Creation(
1029
                backup_task,
1030
            )) => {
1031
                assert_eq!(backup_task.archive_format, Some("zip".to_string()));
1032
            }
1033
            _ => panic!("Expected Creation task"),
1034
        }
1035
    }
1036

1037
    #[tokio::test]
1038
    async fn ipfs_only_mode_sets_no_archive_and_accepts() {
1039
        let db = MockDatabase::default();
1040
        let (tx, mut rx) = mpsc::channel(1);
1041
        let req = BackupRequest {
1042
            tokens: vec![Tokens {
1043
                chain: "ethereum".to_string(),
1044
                tokens: vec!["0xabc:1".to_string()],
1045
            }],
1046
            pin_on_ipfs: true,
1047
            create_archive: false,
1048
        };
1049
        let headers = HeaderMap::new();
1050
        let resp = super::handle_backup_create_core(
1051
            &db,
1052
            &tx,
1053
            Some("did:test".to_string()),
1054
            &headers,
1055
            req,
1056
            7,
1057
        )
1058
        .await
1059
        .into_response();
1060
        assert_eq!(resp.status(), StatusCode::ACCEPTED);
1061

1062
        // Check that the backup task was sent with no archive format (IPFS only mode)
1063
        let task = rx.try_recv().unwrap();
1064
        match task {
1065
            crate::server::BackupTaskOrShutdown::Task(crate::server::TaskType::Creation(
1066
                backup_task,
1067
            )) => {
1068
                assert_eq!(backup_task.archive_format, None);
1069
            }
1070
            _ => panic!("Expected Creation task"),
1071
        }
1072
    }
1073

1074
    #[tokio::test]
1075
    async fn upgrades_to_full_and_enqueues_ipfs_when_archive_exists() {
1076
        let mut db = MockDatabase::default();
1077
        // Existing archive-only task: no ipfs_status present
1078
        db.set_get_backup_task_result(Some(crate::server::database::BackupTask {
1079
            task_id: "t1".to_string(),
1080
            created_at: chrono::Utc::now(),
1081
            updated_at: chrono::Utc::now(),
1082
            requestor: "did:test".to_string(),
1083
            nft_count: 1,
1084
            tokens: serde_json::json!([{"chain":"ethereum","tokens":["0xabc:1"]}]),
1085
            archive_status: Some("done".to_string()),
1086
            ipfs_status: None,
1087
            archive_error_log: None,
1088
            ipfs_error_log: None,
1089
            archive_fatal_error: None,
1090
            ipfs_fatal_error: None,
1091
            storage_mode: "archive".to_string(),
1092
            archive_format: Some("zip".to_string()),
1093
            expires_at: None,
1094
            archive_deleted_at: None,
1095
            pins_deleted_at: None,
1096
        }));
1097
        let (tx, mut rx) = mpsc::channel(1);
1098
        let req = BackupRequest {
1099
            tokens: vec![Tokens {
1100
                chain: "ethereum".to_string(),
1101
                tokens: vec!["0xabc:1".to_string()],
1102
            }],
1103
            pin_on_ipfs: true,
1104
            create_archive: false,
1105
        };
1106
        let headers = HeaderMap::new();
1107
        let resp = super::handle_backup_create_core(
1108
            &db,
1109
            &tx,
1110
            Some("did:test".to_string()),
1111
            &headers,
1112
            req,
1113
            7,
1114
        )
1115
        .await
1116
        .into_response();
1117
        assert_eq!(resp.status(), StatusCode::ACCEPTED);
1118
        // Ensure the missing IPFS subresource task enqueued
1119
        let task = rx.try_recv().unwrap();
1120
        match task {
1121
            crate::server::BackupTaskOrShutdown::Task(crate::server::TaskType::Creation(
1122
                backup_task,
1123
            )) => {
1124
                assert_eq!(backup_task.scope.as_str(), "ipfs");
1125
                assert_eq!(backup_task.archive_format, None);
1126
            }
1127
            _ => panic!("Expected Creation task"),
1128
        }
1129
    }
1130

1131
    #[tokio::test]
1132
    async fn upgrades_to_full_and_enqueues_archive_when_ipfs_exists() {
1133
        let mut db = MockDatabase::default();
1134
        // Existing ipfs-only task: no archive_status present
1135
        db.set_get_backup_task_result(Some(crate::server::database::BackupTask {
1136
            task_id: "t2".to_string(),
1137
            created_at: chrono::Utc::now(),
1138
            updated_at: chrono::Utc::now(),
1139
            requestor: "did:test".to_string(),
1140
            nft_count: 1,
1141
            tokens: serde_json::json!([{"chain":"ethereum","tokens":["0xabc:1"]}]),
1142
            archive_status: None,
1143
            ipfs_status: Some("done".to_string()),
1144
            archive_error_log: None,
1145
            ipfs_error_log: None,
1146
            archive_fatal_error: None,
1147
            ipfs_fatal_error: None,
1148
            storage_mode: "ipfs".to_string(),
1149
            archive_format: None,
1150
            expires_at: None,
1151
            archive_deleted_at: None,
1152
            pins_deleted_at: None,
1153
        }));
1154
        let (tx, mut rx) = mpsc::channel(1);
1155
        let req = BackupRequest {
1156
            tokens: vec![Tokens {
1157
                chain: "ethereum".to_string(),
1158
                tokens: vec!["0xabc:1".to_string()],
1159
            }],
1160
            pin_on_ipfs: false,
1161
            create_archive: true,
1162
        };
1163
        // No accept header -> default zip
1164
        let headers = HeaderMap::new();
1165
        let resp = super::handle_backup_create_core(
1166
            &db,
1167
            &tx,
1168
            Some("did:test".to_string()),
1169
            &headers,
1170
            req,
1171
            7,
1172
        )
1173
        .await
1174
        .into_response();
1175
        assert_eq!(resp.status(), StatusCode::ACCEPTED);
1176
        // Ensure the missing Archive subresource task enqueued with archive format
1177
        let task = rx.try_recv().unwrap();
1178
        match task {
1179
            crate::server::BackupTaskOrShutdown::Task(crate::server::TaskType::Creation(
1180
                backup_task,
1181
            )) => {
1182
                assert_eq!(backup_task.scope.as_str(), "archive");
1183
                assert_eq!(backup_task.archive_format, Some("zip".to_string()));
1184
            }
1185
            _ => panic!("Expected Creation task"),
1186
        }
1187
    }
1188
}
1189

1190
#[cfg(test)]
1191
mod validate_scope_unit_tests {
1192
    use super::validate_scope;
1193
    use crate::server::database::BackupTask;
1194
    use crate::server::StorageMode;
1195

1196
    fn make_meta(archive_deleted: bool, pins_deleted: bool) -> BackupTask {
1197
        BackupTask {
1198
            task_id: "t".to_string(),
1199
            created_at: chrono::Utc::now(),
1200
            updated_at: chrono::Utc::now(),
1201
            requestor: "did:test".to_string(),
1202
            nft_count: 0,
1203
            tokens: serde_json::Value::Null,
1204
            archive_status: None,
1205
            ipfs_status: None,
1206
            archive_error_log: None,
1207
            ipfs_error_log: None,
1208
            archive_fatal_error: None,
1209
            ipfs_fatal_error: None,
1210
            storage_mode: "full".to_string(),
1211
            archive_format: None,
1212
            expires_at: None,
1213
            archive_deleted_at: if archive_deleted {
1214
                Some(chrono::Utc::now())
1215
            } else {
1216
                None
1217
            },
1218
            pins_deleted_at: if pins_deleted {
1219
                Some(chrono::Utc::now())
1220
            } else {
1221
                None
1222
            },
1223
        }
1224
    }
1225

1226
    #[test]
1227
    fn archive_invalid_only_when_archive_deletion_active() {
1228
        let meta = make_meta(true, false);
1229
        assert!(validate_scope(&StorageMode::Archive, &meta));
1230
        let meta = make_meta(false, true);
1231
        assert!(!validate_scope(&StorageMode::Archive, &meta));
1232
        let meta = make_meta(false, false);
1233
        assert!(!validate_scope(&StorageMode::Archive, &meta));
1234
    }
1235

1236
    #[test]
1237
    fn ipfs_invalid_only_when_pins_deletion_active() {
1238
        let meta = make_meta(false, true);
1239
        assert!(validate_scope(&StorageMode::Ipfs, &meta));
1240
        let meta = make_meta(true, false);
1241
        assert!(!validate_scope(&StorageMode::Ipfs, &meta));
1242
        let meta = make_meta(false, false);
1243
        assert!(!validate_scope(&StorageMode::Ipfs, &meta));
1244
    }
1245

1246
    #[test]
1247
    fn full_always_invalid_for_existing_tasks() {
1248
        let meta = make_meta(false, false);
1249
        assert!(validate_scope(&StorageMode::Full, &meta));
1250
        let meta = make_meta(true, true);
1251
        assert!(validate_scope(&StorageMode::Full, &meta));
1252
    }
1253
}
1254

1255
#[cfg(test)]
1256
mod get_status_unit_tests {
1257
    use super::get_status;
1258
    use crate::server::database::BackupTask;
1259
    use crate::server::StorageMode;
1260

1261
    fn base_meta() -> BackupTask {
1262
        BackupTask {
1263
            task_id: "t".to_string(),
1264
            created_at: chrono::Utc::now(),
1265
            updated_at: chrono::Utc::now(),
1266
            requestor: "did:test".to_string(),
1267
            nft_count: 0,
1268
            tokens: serde_json::Value::Null,
1269
            archive_status: None,
1270
            ipfs_status: None,
1271
            archive_error_log: None,
1272
            ipfs_error_log: None,
1273
            archive_fatal_error: None,
1274
            ipfs_fatal_error: None,
1275
            storage_mode: "archive".to_string(),
1276
            archive_format: None,
1277
            expires_at: None,
1278
            archive_deleted_at: None,
1279
            pins_deleted_at: None,
1280
        }
1281
    }
1282

1283
    #[test]
1284
    fn archive_returns_substatus_or_none() {
1285
        let mut meta = base_meta();
1286
        assert_eq!(get_status(&meta, &StorageMode::Archive), None);
1287
        meta.archive_status = Some("done".to_string());
1288
        assert_eq!(
1289
            get_status(&meta, &StorageMode::Archive),
1290
            Some("done".to_string())
1291
        );
1292
    }
1293

1294
    #[test]
1295
    fn ipfs_returns_substatus_or_none() {
1296
        let mut meta = base_meta();
1297
        meta.storage_mode = "ipfs".to_string();
1298
        assert_eq!(get_status(&meta, &StorageMode::Ipfs), None);
1299
        meta.ipfs_status = Some("in_progress".to_string());
1300
        assert_eq!(
1301
            get_status(&meta, &StorageMode::Ipfs),
1302
            Some("in_progress".to_string())
1303
        );
1304
    }
1305
}
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

© 2025 Coveralls, Inc