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