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

0xmichalis / nftbk / 18592856194

17 Oct 2025 12:34PM UTC coverage: 36.062% (-0.3%) from 36.357%
18592856194

push

github

0xmichalis
chore: return null subresources if storage mode is not matching

4 of 67 new or added lines in 2 files covered. (5.97%)

70 existing lines in 1 file now uncovered.

1520 of 4215 relevant lines covered (36.06%)

6.0 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, Clone)]
42
pub struct SubresourceStatus {
43
    /// Subresource status
44
    #[schema(example = "done")]
45
    pub status: Option<String>,
46
    /// Fatal error for the subresource
47
    pub fatal_error: Option<String>,
48
    /// Aggregated non-fatal error log for the subresource
49
    pub error_log: Option<String>,
50
    /// When deletion for this subresource was initiated (ISO 8601)
51
    #[serde(skip_serializing_if = "Option::is_none")]
52
    pub deleted_at: Option<String>,
53
}
54

55
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
56
pub struct Archive {
57
    /// Archive format (zip, tar.gz)
58
    pub format: Option<String>,
59
    /// When the archive expires (ISO 8601)
60
    pub expires_at: Option<String>,
61
    /// Archive status/errors
62
    pub status: SubresourceStatus,
63
}
64

65
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
66
pub struct Pins {
67
    /// IPFS pins status/errors
68
    pub status: SubresourceStatus,
69
}
70

71
#[derive(Debug, Serialize, Deserialize, ToSchema)]
72
pub struct BackupResponse {
73
    /// Unique identifier for the backup task
74
    pub task_id: String,
75
    /// When the backup task was created (ISO 8601)
76
    pub created_at: String,
77
    /// Storage mode used for the backup (archive, ipfs, full)
78
    pub storage_mode: String,
79
    /// Paginated tokens for this task (current page)
80
    pub tokens: Vec<Tokens>,
81
    /// Total number of tokens for this task (for pagination)
82
    pub total_tokens: u32,
83
    /// Current page number
84
    pub page: u32,
85
    /// Page size
86
    pub limit: u32,
87
    /// Archive info (null when storage_mode does not include archive)
88
    #[serde(skip_serializing_if = "Option::is_none")]
89
    pub archive: Option<Archive>,
90
    /// IPFS pins info (null when storage_mode does not include ipfs)
91
    #[serde(skip_serializing_if = "Option::is_none")]
92
    pub pins: Option<Pins>,
93
}
94

95
impl BackupResponse {
96
    pub fn from_backup_task(
6✔
97
        task: &crate::server::database::BackupTask,
98
        tokens: Vec<Tokens>,
99
        total_tokens: u32,
100
        page: u32,
101
        limit: u32,
102
    ) -> Self {
103
        let archive_status = SubresourceStatus {
104
            status: task.archive_status.clone(),
12✔
105
            fatal_error: task.archive_fatal_error.clone(),
12✔
106
            error_log: task.archive_error_log.clone(),
12✔
107
            deleted_at: task
6✔
108
                .archive_deleted_at
109
                .as_ref()
110
                .map(|d: &DateTime<Utc>| d.to_rfc3339()),
111
        };
112
        let pins_status = SubresourceStatus {
113
            status: task.ipfs_status.clone(),
12✔
114
            fatal_error: task.ipfs_fatal_error.clone(),
12✔
115
            error_log: task.ipfs_error_log.clone(),
12✔
116
            deleted_at: task
6✔
117
                .pins_deleted_at
118
                .as_ref()
119
                .map(|d: &DateTime<Utc>| d.to_rfc3339()),
120
        };
121
        let archive = Archive {
122
            status: archive_status,
123
            format: task.archive_format.clone(),
12✔
124
            expires_at: task.expires_at.as_ref().map(|d| d.to_rfc3339()),
20✔
125
        };
126
        let pins = Pins {
127
            status: pins_status,
128
        };
129
        let (archive_opt, pins_opt) = match task.storage_mode.as_str() {
18✔
130
            "archive" => (Some(archive), None),
10✔
131
            "ipfs" => (None, Some(pins)),
3✔
132
            "full" => (Some(archive), Some(pins)),
2✔
NEW
133
            _ => (None, None),
×
134
        };
135
        BackupResponse {
136
            task_id: task.task_id.clone(),
18✔
137
            created_at: task.created_at.to_rfc3339(),
18✔
138
            storage_mode: task.storage_mode.clone(),
18✔
139
            tokens,
140
            total_tokens,
141
            page,
142
            limit,
143
            archive: archive_opt,
144
            pins: pins_opt,
145
        }
146
    }
147
}
148

149
#[cfg(test)]
150
mod from_backup_task_tests {
151
    use super::{BackupResponse, Tokens};
152
    use crate::server::database::BackupTask;
153
    use chrono::{TimeZone, Utc};
154

155
    fn sample_task() -> BackupTask {
156
        BackupTask {
157
            task_id: "task123".to_string(),
158
            created_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(),
159
            updated_at: Utc.timestamp_opt(1_700_000_500, 0).unwrap(),
160
            requestor: "did:privy:alice".to_string(),
161
            nft_count: 3,
162
            tokens: serde_json::json!([]),
163
            archive_status: Some("done".to_string()),
164
            ipfs_status: Some("in_progress".to_string()),
165
            archive_error_log: Some("arch warnings".to_string()),
166
            ipfs_error_log: None,
167
            archive_fatal_error: None,
168
            ipfs_fatal_error: Some("provider error".to_string()),
169
            storage_mode: "full".to_string(),
170
            archive_format: Some("zip".to_string()),
171
            expires_at: Some(Utc.timestamp_opt(1_700_086_400, 0).unwrap()), // +1 day
172
            archive_deleted_at: Some(Utc.timestamp_opt(1_700_000_800, 0).unwrap()),
173
            pins_deleted_at: Some(Utc.timestamp_opt(1_700_000_900, 0).unwrap()),
174
        }
175
    }
176

177
    #[test]
178
    fn maps_all_fields_with_some_values() {
179
        let task = sample_task();
180
        let tokens = vec![Tokens {
181
            chain: "ethereum".to_string(),
182
            tokens: vec!["0xabc:1".to_string(), "0xabc:2".to_string()],
183
        }];
184
        let resp = BackupResponse::from_backup_task(&task, tokens.clone(), 3, 2, 50);
185
        assert_eq!(resp.task_id, task.task_id);
186
        assert_eq!(resp.created_at, task.created_at.to_rfc3339());
187
        assert_eq!(resp.storage_mode, task.storage_mode);
188
        assert_eq!(resp.total_tokens, 3);
189
        assert_eq!(resp.page, 2);
190
        assert_eq!(resp.limit, 50);
191
        assert_eq!(resp.tokens.len(), 1);
192
        assert_eq!(resp.tokens[0].chain, "ethereum");
193
        let archive = resp.archive.as_ref().unwrap();
194
        assert_eq!(archive.format.as_deref(), Some("zip"));
195
        assert_eq!(
196
            archive.expires_at.as_deref(),
197
            Some(task.expires_at.unwrap().to_rfc3339().as_str())
198
        );
199
        // Archive status
200
        assert_eq!(archive.status.status.as_deref(), Some("done"));
201
        assert_eq!(archive.status.error_log.as_deref(), Some("arch warnings"));
202
        assert_eq!(archive.status.fatal_error, None);
203
        assert_eq!(
204
            archive.status.deleted_at.as_deref(),
205
            Some(task.archive_deleted_at.unwrap().to_rfc3339().as_str())
206
        );
207
        // Pins status
208
        let pins = resp.pins.as_ref().unwrap();
209
        assert_eq!(pins.status.status.as_deref(), Some("in_progress"));
210
        assert_eq!(pins.status.error_log, None);
211
        assert_eq!(pins.status.fatal_error.as_deref(), Some("provider error"));
212
        assert_eq!(
213
            pins.status.deleted_at.as_deref(),
214
            Some(task.pins_deleted_at.unwrap().to_rfc3339().as_str())
215
        );
216
    }
217

218
    #[test]
219
    fn maps_none_values_as_none() {
220
        let mut task = sample_task();
221
        task.archive_status = None;
222
        task.ipfs_status = None;
223
        task.archive_error_log = None;
224
        task.ipfs_error_log = None;
225
        task.archive_fatal_error = None;
226
        task.ipfs_fatal_error = None;
227
        task.archive_format = None;
228
        task.expires_at = None;
229
        task.archive_deleted_at = None;
230
        task.pins_deleted_at = None;
231
        task.storage_mode = "ipfs".to_string();
232

233
        let resp = BackupResponse::from_backup_task(&task, Vec::new(), 0, 1, 10);
234
        assert!(resp.archive.is_none());
235
        assert!(resp.pins.is_some());
236
        assert!(resp.tokens.is_empty());
237
        assert_eq!(resp.total_tokens, 0);
238
        assert_eq!(resp.page, 1);
239
        assert_eq!(resp.limit, 10);
240
    }
241
}
242

243
// RFC 7807 problem+json error shape
244
#[derive(Debug, Serialize, Deserialize, ToSchema)]
245
pub struct ApiProblem {
246
    /// A URI reference that identifies the problem type
247
    #[serde(default = "default_problem_type")]
248
    #[schema(example = "about:blank")]
249
    pub r#type: String,
250
    /// A short, human-readable summary of the problem type
251
    #[schema(example = "Bad Request")]
252
    pub title: String,
253
    /// The HTTP status code generated by the origin server for this occurrence of the problem
254
    #[schema(example = 400, minimum = 100, maximum = 599)]
255
    pub status: u16,
256
    /// A human-readable explanation specific to this occurrence of the problem
257
    #[serde(skip_serializing_if = "Option::is_none")]
258
    #[schema(example = "The request body is invalid")]
259
    pub detail: Option<String>,
260
    /// A URI reference that identifies the specific occurrence of the problem
261
    #[serde(skip_serializing_if = "Option::is_none")]
262
    #[schema(example = "/v1/backups")]
263
    pub instance: Option<String>,
264
}
265

266
fn default_problem_type() -> String {
29✔
267
    "about:blank".to_string()
58✔
268
}
269

270
impl ApiProblem {
271
    pub fn new(status: StatusCode, detail: Option<String>, instance: Option<String>) -> Self {
28✔
272
        let title = status.canonical_reason().unwrap_or("Error").to_string();
140✔
273
        Self {
274
            r#type: default_problem_type(),
56✔
275
            title,
276
            status: status.as_u16(),
84✔
277
            detail,
278
            instance,
279
        }
280
    }
281
    pub fn new_with_title(
1✔
282
        status: StatusCode,
283
        title: &str,
284
        detail: Option<String>,
285
        instance: Option<String>,
286
    ) -> Self {
287
        Self {
288
            r#type: default_problem_type(),
2✔
289
            title: title.to_string(),
3✔
290
            status: status.as_u16(),
3✔
291
            detail,
292
            instance,
293
        }
294
    }
295
}
296

297
/// Responder that serializes an `ApiProblem` with `application/problem+json` content type
298
pub struct ProblemJson(pub ApiProblem);
299

300
impl ProblemJson {
301
    pub fn from_status(
26✔
302
        status: StatusCode,
303
        detail: Option<String>,
304
        instance: Option<String>,
305
    ) -> Self {
306
        Self(ApiProblem::new(status, detail, instance))
78✔
307
    }
308
}
309

310
impl IntoResponse for ProblemJson {
311
    fn into_response(self) -> axum::response::Response {
26✔
312
        let mut headers = HeaderMap::new();
52✔
313
        headers.insert(
52✔
314
            header::CONTENT_TYPE,
26✔
315
            header::HeaderValue::from_static("application/problem+json"),
26✔
316
        );
317
        let status =
26✔
318
            StatusCode::from_u16(self.0.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
78✔
319
        (status, headers, Json(self.0)).into_response()
78✔
320
    }
321
}
322

323
#[cfg(test)]
324
mod api_problem_tests {
325
    use super::ApiProblem;
326
    use axum::http::StatusCode;
327

328
    #[test]
329
    fn derives_title_from_status() {
330
        let p = ApiProblem::new(
331
            StatusCode::BAD_REQUEST,
332
            Some("detail".to_string()),
333
            Some("/v1/x".to_string()),
334
        );
335
        assert_eq!(p.title, "Bad Request");
336
        assert_eq!(p.status, 400);
337
        assert_eq!(p.r#type, "about:blank");
338
        assert_eq!(p.instance.as_deref(), Some("/v1/x"));
339
        assert_eq!(p.detail.as_deref(), Some("detail"));
340
    }
341

342
    #[test]
343
    fn allows_custom_title() {
344
        let p = ApiProblem::new_with_title(StatusCode::NOT_FOUND, "Resource Missing", None, None);
345
        assert_eq!(p.title, "Resource Missing");
346
        assert_eq!(p.status, 404);
347
    }
348

349
    #[test]
350
    fn serializes_and_deserializes() {
351
        let p = ApiProblem::new(
352
            StatusCode::UNAUTHORIZED,
353
            Some("auth missing".to_string()),
354
            Some("/v1/secure".to_string()),
355
        );
356
        let json = serde_json::to_string(&p).unwrap();
357
        let back: ApiProblem = serde_json::from_str(&json).unwrap();
358
        assert_eq!(back.title, p.title);
359
        assert_eq!(back.status, p.status);
360
        assert_eq!(back.instance, p.instance);
361
        assert_eq!(back.detail, p.detail);
362
    }
363
}
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