• 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

97.56
/src/server/api.rs
1
use axum::http::{header, HeaderMap, StatusCode};
2
use axum::response::IntoResponse;
3
use axum::Json;
4
use chrono::{DateTime, Utc};
5
use serde::{Deserialize, Serialize};
6
use utoipa::ToSchema;
7

8
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
9
pub struct Tokens {
10
    /// The blockchain identifier (ethereum, polygon, tezos, base, arbitrum)
11
    #[schema(example = "ethereum")]
12
    pub chain: String,
13
    /// List of NFT token identifiers/contract addresses
14
    /// The tokens are formatted as `contract_address:token_id` (e.g., `0x1234567890123456789012345678901234567890:1`)
15
    #[schema(example = json!(["0x1234567890123456789012345678901234567890:1", "0x1234567890123456789012345678901234567890:2"]))]
16
    pub tokens: Vec<String>,
17
}
18

19
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
20
pub struct BackupRequest {
21
    /// List of tokens to backup, organized by blockchain
22
    pub tokens: Vec<Tokens>,
23
    /// When true, pin downloaded assets to configured IPFS provider(s)
24
    #[serde(default)]
25
    pub pin_on_ipfs: bool,
26
    /// When true, create an archive of downloaded assets; when false, skip archiving
27
    #[serde(default = "default_true")]
28
    pub create_archive: bool,
29
}
30

31
fn default_true() -> bool {
1✔
32
    true
1✔
33
}
34

35
#[derive(Debug, Serialize, Deserialize, ToSchema)]
36
pub struct BackupCreateResponse {
37
    /// Unique identifier for the backup task
38
    pub task_id: String,
39
}
40

41
#[derive(Debug, Serialize, Deserialize, ToSchema)]
42
pub struct QuoteCreateResponse {
43
    /// Unique identifier for the quote
44
    #[schema(example = "550e8400-e29b-41d4-a716-446655440000")]
45
    pub quote_id: String,
46
}
47

48
#[derive(Debug, Serialize, Deserialize, ToSchema)]
49
pub struct QuoteResponse {
50
    /// Unique identifier for the quote
51
    #[schema(example = "550e8400-e29b-41d4-a716-446655440000")]
52
    pub quote_id: String,
53
    /// Price in wei (e.g., "100000" for 0.1 USDC)
54
    /// None if the quote is still being computed
55
    #[schema(example = "100000")]
56
    pub price: Option<String>,
57
    /// Asset symbol (e.g., "USDC")
58
    /// None if the quote is still being computed
59
    #[schema(example = "USDC")]
60
    pub asset_symbol: Option<String>,
61
    /// Network identifier (e.g., "base", "base-sepolia")
62
    /// None if the quote is still being computed
63
    #[schema(example = "base-sepolia")]
64
    pub network: Option<String>,
65
    /// Estimated total size of the backup in bytes
66
    /// None if the quote is still being computed
67
    #[schema(example = 1073741824)]
68
    pub estimated_size_bytes: Option<u64>,
69
}
70

71
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
72
pub struct SubresourceStatus {
73
    /// Subresource status
74
    #[schema(example = "done")]
75
    pub status: Option<String>,
76
    /// Fatal error for the subresource
77
    pub fatal_error: Option<String>,
78
    /// Aggregated non-fatal error log for the subresource
79
    pub error_log: Option<String>,
80
    /// When deletion for this subresource was initiated (ISO 8601)
81
    #[serde(skip_serializing_if = "Option::is_none")]
82
    pub deleted_at: Option<String>,
83
}
84

85
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
86
pub struct Archive {
87
    /// Archive format (zip, tar.gz)
88
    pub format: Option<String>,
89
    /// When the archive expires (ISO 8601)
90
    pub expires_at: Option<String>,
91
    /// Archive status/errors
92
    pub status: SubresourceStatus,
93
}
94

95
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
96
pub struct Pins {
97
    /// IPFS pins status/errors
98
    pub status: SubresourceStatus,
99
}
100

101
#[derive(Debug, Serialize, Deserialize, ToSchema)]
102
pub struct BackupResponse {
103
    /// Unique identifier for the backup task
104
    pub task_id: String,
105
    /// When the backup task was created (ISO 8601)
106
    pub created_at: String,
107
    /// Storage mode used for the backup (archive, ipfs, full)
108
    pub storage_mode: String,
109
    /// Paginated tokens for this task (current page)
110
    pub tokens: Vec<Tokens>,
111
    /// Total number of tokens for this task (for pagination)
112
    pub total_tokens: u32,
113
    /// Current page number
114
    pub page: u32,
115
    /// Page size
116
    pub limit: u32,
117
    /// Archive info (null when storage_mode does not include archive)
118
    #[serde(skip_serializing_if = "Option::is_none")]
119
    pub archive: Option<Archive>,
120
    /// IPFS pins info (null when storage_mode does not include ipfs)
121
    #[serde(skip_serializing_if = "Option::is_none")]
122
    pub pins: Option<Pins>,
123
}
124

125
impl BackupResponse {
126
    pub fn from_backup_task(
6✔
127
        task: &crate::server::database::BackupTask,
128
        tokens: Vec<Tokens>,
129
        total_tokens: u32,
130
        page: u32,
131
        limit: u32,
132
    ) -> Self {
133
        let archive_status = SubresourceStatus {
134
            status: task.archive_status.clone(),
12✔
135
            fatal_error: task.archive_fatal_error.clone(),
12✔
136
            error_log: task.archive_error_log.clone(),
12✔
137
            deleted_at: task
6✔
138
                .archive_deleted_at
139
                .as_ref()
140
                .map(|d: &DateTime<Utc>| d.to_rfc3339()),
141
        };
142
        let pins_status = SubresourceStatus {
143
            status: task.ipfs_status.clone(),
12✔
144
            fatal_error: task.ipfs_fatal_error.clone(),
12✔
145
            error_log: task.ipfs_error_log.clone(),
12✔
146
            deleted_at: task
6✔
147
                .pins_deleted_at
148
                .as_ref()
149
                .map(|d: &DateTime<Utc>| d.to_rfc3339()),
150
        };
151
        let archive = Archive {
152
            status: archive_status,
153
            format: task.archive_format.clone(),
12✔
154
            expires_at: task.expires_at.as_ref().map(|d| d.to_rfc3339()),
20✔
155
        };
156
        let pins = Pins {
157
            status: pins_status,
158
        };
159
        let (archive_opt, pins_opt) = match task.storage_mode.as_str() {
18✔
160
            "archive" => (Some(archive), None),
10✔
161
            "ipfs" => (None, Some(pins)),
3✔
162
            "full" => (Some(archive), Some(pins)),
2✔
UNCOV
163
            _ => (None, None),
×
164
        };
165
        BackupResponse {
166
            task_id: task.task_id.clone(),
18✔
167
            created_at: task.created_at.to_rfc3339(),
18✔
168
            storage_mode: task.storage_mode.clone(),
18✔
169
            tokens,
170
            total_tokens,
171
            page,
172
            limit,
173
            archive: archive_opt,
174
            pins: pins_opt,
175
        }
176
    }
177
}
178

179
#[cfg(test)]
180
mod from_backup_task_tests {
181
    use super::{BackupResponse, Tokens};
182
    use crate::server::database::BackupTask;
183
    use chrono::{TimeZone, Utc};
184

185
    fn sample_task() -> BackupTask {
186
        BackupTask {
187
            task_id: "task123".to_string(),
188
            created_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(),
189
            updated_at: Utc.timestamp_opt(1_700_000_500, 0).unwrap(),
190
            requestor: "did:privy:alice".to_string(),
191
            nft_count: 3,
192
            tokens: serde_json::json!([]),
193
            archive_status: Some("done".to_string()),
194
            ipfs_status: Some("in_progress".to_string()),
195
            archive_error_log: Some("arch warnings".to_string()),
196
            ipfs_error_log: None,
197
            archive_fatal_error: None,
198
            ipfs_fatal_error: Some("provider error".to_string()),
199
            storage_mode: "full".to_string(),
200
            archive_format: Some("zip".to_string()),
201
            expires_at: Some(Utc.timestamp_opt(1_700_086_400, 0).unwrap()), // +1 day
202
            archive_deleted_at: Some(Utc.timestamp_opt(1_700_000_800, 0).unwrap()),
203
            pins_deleted_at: Some(Utc.timestamp_opt(1_700_000_900, 0).unwrap()),
204
        }
205
    }
206

207
    #[test]
208
    fn maps_all_fields_with_some_values() {
209
        let task = sample_task();
210
        let tokens = vec![Tokens {
211
            chain: "ethereum".to_string(),
212
            tokens: vec!["0xabc:1".to_string(), "0xabc:2".to_string()],
213
        }];
214
        let resp = BackupResponse::from_backup_task(&task, tokens.clone(), 3, 2, 50);
215
        assert_eq!(resp.task_id, task.task_id);
216
        assert_eq!(resp.created_at, task.created_at.to_rfc3339());
217
        assert_eq!(resp.storage_mode, task.storage_mode);
218
        assert_eq!(resp.total_tokens, 3);
219
        assert_eq!(resp.page, 2);
220
        assert_eq!(resp.limit, 50);
221
        assert_eq!(resp.tokens.len(), 1);
222
        assert_eq!(resp.tokens[0].chain, "ethereum");
223
        let archive = resp.archive.as_ref().unwrap();
224
        assert_eq!(archive.format.as_deref(), Some("zip"));
225
        assert_eq!(
226
            archive.expires_at.as_deref(),
227
            Some(task.expires_at.unwrap().to_rfc3339().as_str())
228
        );
229
        // Archive status
230
        assert_eq!(archive.status.status.as_deref(), Some("done"));
231
        assert_eq!(archive.status.error_log.as_deref(), Some("arch warnings"));
232
        assert_eq!(archive.status.fatal_error, None);
233
        assert_eq!(
234
            archive.status.deleted_at.as_deref(),
235
            Some(task.archive_deleted_at.unwrap().to_rfc3339().as_str())
236
        );
237
        // Pins status
238
        let pins = resp.pins.as_ref().unwrap();
239
        assert_eq!(pins.status.status.as_deref(), Some("in_progress"));
240
        assert_eq!(pins.status.error_log, None);
241
        assert_eq!(pins.status.fatal_error.as_deref(), Some("provider error"));
242
        assert_eq!(
243
            pins.status.deleted_at.as_deref(),
244
            Some(task.pins_deleted_at.unwrap().to_rfc3339().as_str())
245
        );
246
    }
247

248
    #[test]
249
    fn maps_none_values_as_none() {
250
        let mut task = sample_task();
251
        task.archive_status = None;
252
        task.ipfs_status = None;
253
        task.archive_error_log = None;
254
        task.ipfs_error_log = None;
255
        task.archive_fatal_error = None;
256
        task.ipfs_fatal_error = None;
257
        task.archive_format = None;
258
        task.expires_at = None;
259
        task.archive_deleted_at = None;
260
        task.pins_deleted_at = None;
261
        task.storage_mode = "ipfs".to_string();
262

263
        let resp = BackupResponse::from_backup_task(&task, Vec::new(), 0, 1, 10);
264
        assert!(resp.archive.is_none());
265
        assert!(resp.pins.is_some());
266
        assert!(resp.tokens.is_empty());
267
        assert_eq!(resp.total_tokens, 0);
268
        assert_eq!(resp.page, 1);
269
        assert_eq!(resp.limit, 10);
270
    }
271
}
272

273
// RFC 7807 problem+json error shape
274
#[derive(Debug, Serialize, Deserialize, ToSchema)]
275
pub struct ApiProblem {
276
    /// A URI reference that identifies the problem type
277
    #[serde(default = "default_problem_type")]
278
    #[schema(example = "about:blank")]
279
    pub r#type: String,
280
    /// A short, human-readable summary of the problem type
281
    #[schema(example = "Bad Request")]
282
    pub title: String,
283
    /// The HTTP status code generated by the origin server for this occurrence of the problem
284
    #[schema(example = 400, minimum = 100, maximum = 599)]
285
    pub status: u16,
286
    /// A human-readable explanation specific to this occurrence of the problem
287
    #[serde(skip_serializing_if = "Option::is_none")]
288
    #[schema(example = "The request body is invalid")]
289
    pub detail: Option<String>,
290
    /// A URI reference that identifies the specific occurrence of the problem
291
    #[serde(skip_serializing_if = "Option::is_none")]
292
    #[schema(example = "/v1/backups")]
293
    pub instance: Option<String>,
294
}
295

296
fn default_problem_type() -> String {
47✔
297
    "about:blank".to_string()
94✔
298
}
299

300
impl ApiProblem {
301
    pub fn new(status: StatusCode, detail: Option<String>, instance: Option<String>) -> Self {
46✔
302
        let title = status.canonical_reason().unwrap_or("Error").to_string();
230✔
303
        Self {
304
            r#type: default_problem_type(),
92✔
305
            title,
306
            status: status.as_u16(),
138✔
307
            detail,
308
            instance,
309
        }
310
    }
311
    pub fn new_with_title(
1✔
312
        status: StatusCode,
313
        title: &str,
314
        detail: Option<String>,
315
        instance: Option<String>,
316
    ) -> Self {
317
        Self {
318
            r#type: default_problem_type(),
2✔
319
            title: title.to_string(),
3✔
320
            status: status.as_u16(),
3✔
321
            detail,
322
            instance,
323
        }
324
    }
325
}
326

327
/// Responder that serializes an `ApiProblem` with `application/problem+json` content type
328
pub struct ProblemJson(pub ApiProblem);
329

330
impl ProblemJson {
331
    pub fn from_status(
44✔
332
        status: StatusCode,
333
        detail: Option<String>,
334
        instance: Option<String>,
335
    ) -> Self {
336
        Self(ApiProblem::new(status, detail, instance))
132✔
337
    }
338
}
339

340
impl IntoResponse for ProblemJson {
341
    fn into_response(self) -> axum::response::Response {
44✔
342
        let mut headers = HeaderMap::new();
88✔
343
        headers.insert(
88✔
344
            header::CONTENT_TYPE,
44✔
345
            header::HeaderValue::from_static("application/problem+json"),
44✔
346
        );
347
        let status =
44✔
348
            StatusCode::from_u16(self.0.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
132✔
349
        (status, headers, Json(self.0)).into_response()
132✔
350
    }
351
}
352

353
#[cfg(test)]
354
mod api_problem_tests {
355
    use super::ApiProblem;
356
    use axum::http::StatusCode;
357

358
    #[test]
359
    fn derives_title_from_status() {
360
        let p = ApiProblem::new(
361
            StatusCode::BAD_REQUEST,
362
            Some("detail".to_string()),
363
            Some("/v1/x".to_string()),
364
        );
365
        assert_eq!(p.title, "Bad Request");
366
        assert_eq!(p.status, 400);
367
        assert_eq!(p.r#type, "about:blank");
368
        assert_eq!(p.instance.as_deref(), Some("/v1/x"));
369
        assert_eq!(p.detail.as_deref(), Some("detail"));
370
    }
371

372
    #[test]
373
    fn allows_custom_title() {
374
        let p = ApiProblem::new_with_title(StatusCode::NOT_FOUND, "Resource Missing", None, None);
375
        assert_eq!(p.title, "Resource Missing");
376
        assert_eq!(p.status, 404);
377
    }
378

379
    #[test]
380
    fn serializes_and_deserializes() {
381
        let p = ApiProblem::new(
382
            StatusCode::UNAUTHORIZED,
383
            Some("auth missing".to_string()),
384
            Some("/v1/secure".to_string()),
385
        );
386
        let json = serde_json::to_string(&p).unwrap();
387
        let back: ApiProblem = serde_json::from_str(&json).unwrap();
388
        assert_eq!(back.title, p.title);
389
        assert_eq!(back.status, p.status);
390
        assert_eq!(back.instance, p.instance);
391
        assert_eq!(back.detail, p.detail);
392
    }
393
}
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