1use crate::AppState;
4use axum::Json;
5use axum::extract::State;
6use axum::response::IntoResponse;
7use serde::Deserialize;
8use std::sync::Arc;
9
10use super::{error_response, status_for};
11
12#[derive(Debug, Deserialize)]
14pub struct ExportRequest {
15 pub address: String,
17 #[serde(default = "super::default_chain")]
19 pub chain: String,
20 #[serde(default = "default_format")]
22 pub format: String,
23 pub start_date: Option<String>,
25 pub end_date: Option<String>,
27}
28
29fn default_format() -> String {
30 "json".to_string()
31}
32
33pub async fn handle(
42 State(state): State<Arc<AppState>>,
43 Json(req): Json<ExportRequest>,
44) -> impl IntoResponse {
45 let resolved = match super::resolve_address_book(&req.address, &state.config) {
47 Ok(r) => r,
48 Err(e) => return error_response(status_for(&e), e.to_string()),
49 };
50 let address = resolved.value;
51 let chain = resolved.chain.unwrap_or(req.chain);
52
53 let client: Box<dyn scope::chains::ChainClient> =
54 match state.factory.create_chain_client(&chain) {
55 Ok(c) => c,
56 Err(e) => return error_response(status_for(&e), e.to_string()),
57 };
58
59 let mut balance = match client.get_balance(&address).await {
61 Ok(b) => b,
62 Err(e) => return error_response(status_for(&e), e.to_string()),
63 };
64 client.enrich_balance_usd(&mut balance).await;
65
66 let txs = client.get_transactions(&address, 100).await.ok();
68
69 let tokens = client.get_token_balances(&address).await.ok();
71
72 Json(serde_json::json!({
73 "address": address,
74 "chain": chain,
75 "format": req.format,
76 "balance": {
77 "raw": balance.raw,
78 "formatted": balance.formatted,
79 "usd_value": balance.usd_value,
80 },
81 "transactions": txs,
82 "tokens": tokens,
83 }))
84 .into_response()
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 #[test]
92 fn test_deserialize_full() {
93 let json = serde_json::json!({
94 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
95 "chain": "polygon",
96 "format": "csv",
97 "start_date": "2024-01-01T00:00:00Z",
98 "end_date": "2024-12-31T23:59:59Z"
99 });
100 let req: ExportRequest = serde_json::from_value(json).unwrap();
101 assert_eq!(req.address, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
102 assert_eq!(req.chain, "polygon");
103 assert_eq!(req.format, "csv");
104 assert_eq!(req.start_date, Some("2024-01-01T00:00:00Z".to_string()));
105 assert_eq!(req.end_date, Some("2024-12-31T23:59:59Z".to_string()));
106 }
107
108 #[test]
109 fn test_deserialize_minimal() {
110 let json = serde_json::json!({
111 "address": "0x1234567890123456789012345678901234567890"
112 });
113 let req: ExportRequest = serde_json::from_value(json).unwrap();
114 assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
115 assert_eq!(req.chain, "ethereum");
116 assert_eq!(req.format, "json");
117 assert_eq!(req.start_date, None);
118 assert_eq!(req.end_date, None);
119 }
120
121 #[test]
122 fn test_defaults() {
123 assert_eq!(super::super::default_chain(), "ethereum");
124 assert_eq!(default_format(), "json");
125 }
126
127 #[test]
128 fn test_with_date_filters() {
129 let json = serde_json::json!({
130 "address": "0xabcdef1234567890abcdef1234567890abcdef1234",
131 "start_date": "2024-06-01T00:00:00Z",
132 "end_date": "2024-06-30T23:59:59Z"
133 });
134 let req: ExportRequest = serde_json::from_value(json).unwrap();
135 assert_eq!(req.address, "0xabcdef1234567890abcdef1234567890abcdef1234");
136 assert_eq!(req.chain, "ethereum");
137 assert_eq!(req.format, "json");
138 assert_eq!(req.start_date, Some("2024-06-01T00:00:00Z".to_string()));
139 assert_eq!(req.end_date, Some("2024-06-30T23:59:59Z".to_string()));
140 }
141
142 #[tokio::test]
143 async fn test_handle_export_direct() {
144 use crate::AppState;
145 use axum::extract::State;
146 use axum::response::IntoResponse;
147 use scope::chains::DefaultClientFactory;
148 use scope::config::Config;
149
150 let config = Config::default();
151 let http: std::sync::Arc<dyn scope::http::HttpClient> =
152 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
153 let factory = DefaultClientFactory {
154 chains_config: config.chains.clone(),
155 http,
156 };
157 let state = std::sync::Arc::new(AppState::new(config, factory));
158 let req = ExportRequest {
159 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
160 chain: "ethereum".to_string(),
161 format: "json".to_string(),
162 start_date: None,
163 end_date: None,
164 };
165 let response = handle(State(state), axum::Json(req)).await.into_response();
166 let status = response.status();
167 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
168 }
169
170 #[tokio::test]
171 async fn test_handle_export_success_json_structure() {
172 use crate::AppState;
173 use axum::body;
174 use axum::extract::State;
175 use axum::response::IntoResponse;
176 use scope::chains::DefaultClientFactory;
177 use scope::config::Config;
178
179 let config = Config::default();
180 let http: std::sync::Arc<dyn scope::http::HttpClient> =
181 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
182 let factory = DefaultClientFactory {
183 chains_config: config.chains.clone(),
184 http,
185 };
186 let state = std::sync::Arc::new(AppState::new(config, factory));
187 let req = ExportRequest {
188 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
189 chain: "ethereum".to_string(),
190 format: "json".to_string(),
191 start_date: None,
192 end_date: None,
193 };
194 let response = handle(State(state), axum::Json(req)).await.into_response();
195 if response.status().is_success() {
196 let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
197 .await
198 .unwrap();
199 let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
200 assert!(json.get("address").is_some());
201 assert!(json.get("chain").is_some());
202 assert!(json.get("format").is_some());
203 assert!(json.get("balance").is_some());
204 }
205 }
206
207 #[test]
208 fn test_export_request_debug() {
209 let req = ExportRequest {
210 address: "0xabc".to_string(),
211 chain: "ethereum".to_string(),
212 format: "json".to_string(),
213 start_date: None,
214 end_date: None,
215 };
216 let debug = format!("{:?}", req);
217 assert!(debug.contains("ExportRequest"));
218 }
219
220 #[test]
221 fn test_deserialize_export_csv_format() {
222 let json = serde_json::json!({
223 "address": "0x1234567890123456789012345678901234567890",
224 "format": "csv"
225 });
226 let req: ExportRequest = serde_json::from_value(json).unwrap();
227 assert_eq!(req.format, "csv");
228 }
229
230 #[tokio::test]
231 async fn test_handle_export_unsupported_chain_bad_request() {
232 use crate::AppState;
233 use axum::extract::State;
234 use axum::http::StatusCode;
235 use axum::response::IntoResponse;
236 use scope::chains::DefaultClientFactory;
237 use scope::config::Config;
238
239 let tmp = tempfile::tempdir().unwrap();
241 let config = Config {
242 address_book: scope::config::AddressBookConfig {
243 data_dir: Some(tmp.path().to_path_buf()),
244 },
245 ..Default::default()
246 };
247 let http: std::sync::Arc<dyn scope::http::HttpClient> =
248 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
249 let factory = DefaultClientFactory {
250 chains_config: config.chains.clone(),
251 http,
252 };
253 let state = std::sync::Arc::new(AppState::new(config, factory));
254 let req = ExportRequest {
255 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
256 chain: "bitcoin".to_string(), format: "json".to_string(),
258 start_date: None,
259 end_date: None,
260 };
261 let response = handle(State(state), axum::Json(req)).await.into_response();
262 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
263 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
264 .await
265 .unwrap();
266 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
267 assert!(
268 json["error"]
269 .as_str()
270 .unwrap()
271 .contains("Unsupported chain")
272 );
273 }
274
275 #[tokio::test]
276 async fn test_handle_export_label_not_found() {
277 use crate::AppState;
278 use axum::extract::State;
279 use axum::http::StatusCode;
280 use axum::response::IntoResponse;
281 use scope::chains::DefaultClientFactory;
282 use scope::config::Config;
283
284 let tmp = tempfile::tempdir().unwrap();
285 let config = Config {
286 address_book: scope::config::AddressBookConfig {
287 data_dir: Some(tmp.path().to_path_buf()),
288 },
289 ..Default::default()
290 };
291 let http: std::sync::Arc<dyn scope::http::HttpClient> =
292 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
293 let factory = DefaultClientFactory {
294 chains_config: config.chains.clone(),
295 http,
296 };
297 let state = std::sync::Arc::new(AppState::new(config, factory));
298 let req = ExportRequest {
299 address: "@ghost-wallet".to_string(),
300 chain: "ethereum".to_string(),
301 format: "json".to_string(),
302 start_date: None,
303 end_date: None,
304 };
305 let response = handle(State(state), axum::Json(req)).await.into_response();
306 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
307 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
308 .await
309 .unwrap();
310 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
311 assert!(json["error"].as_str().unwrap().contains("@ghost-wallet"));
312 }
313}