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 factory = DefaultClientFactory {
173 chains_config: config.chains.clone(),
174 };
175 let state = std::sync::Arc::new(AppState { config, factory });
176 let req = ExportRequest {
177 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
178 chain: "ethereum".to_string(),
179 format: "json".to_string(),
180 start_date: None,
181 end_date: None,
182 };
183 let response = handle(State(state), axum::Json(req)).await.into_response();
184 let status = response.status();
185 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
186 }
187
188 #[tokio::test]
189 async fn test_handle_export_success_json_structure() {
190 use crate::chains::DefaultClientFactory;
191 use crate::config::Config;
192 use crate::web::AppState;
193 use axum::body;
194 use axum::extract::State;
195 use axum::response::IntoResponse;
196
197 let config = Config::default();
198 let factory = DefaultClientFactory {
199 chains_config: config.chains.clone(),
200 };
201 let state = std::sync::Arc::new(AppState { config, factory });
202 let req = ExportRequest {
203 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
204 chain: "ethereum".to_string(),
205 format: "json".to_string(),
206 start_date: None,
207 end_date: None,
208 };
209 let response = handle(State(state), axum::Json(req)).await.into_response();
210 if response.status().is_success() {
211 let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
212 .await
213 .unwrap();
214 let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
215 assert!(json.get("address").is_some());
216 assert!(json.get("chain").is_some());
217 assert!(json.get("format").is_some());
218 assert!(json.get("balance").is_some());
219 }
220 }
221
222 #[test]
223 fn test_export_request_debug() {
224 let req = ExportRequest {
225 address: "0xabc".to_string(),
226 chain: "ethereum".to_string(),
227 format: "json".to_string(),
228 start_date: None,
229 end_date: None,
230 };
231 let debug = format!("{:?}", req);
232 assert!(debug.contains("ExportRequest"));
233 }
234
235 #[test]
236 fn test_deserialize_export_csv_format() {
237 let json = serde_json::json!({
238 "address": "0x1234567890123456789012345678901234567890",
239 "format": "csv"
240 });
241 let req: ExportRequest = serde_json::from_value(json).unwrap();
242 assert_eq!(req.format, "csv");
243 }
244
245 #[tokio::test]
246 async fn test_handle_export_unsupported_chain_bad_request() {
247 use crate::chains::DefaultClientFactory;
248 use crate::config::Config;
249 use crate::web::AppState;
250 use axum::extract::State;
251 use axum::http::StatusCode;
252 use axum::response::IntoResponse;
253
254 let tmp = tempfile::tempdir().unwrap();
256 let config = Config {
257 address_book: crate::config::AddressBookConfig {
258 data_dir: Some(tmp.path().to_path_buf()),
259 },
260 ..Default::default()
261 };
262 let factory = DefaultClientFactory {
263 chains_config: config.chains.clone(),
264 };
265 let state = std::sync::Arc::new(AppState { config, factory });
266 let req = ExportRequest {
267 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
268 chain: "bitcoin".to_string(), format: "json".to_string(),
270 start_date: None,
271 end_date: None,
272 };
273 let response = handle(State(state), axum::Json(req)).await.into_response();
274 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
275 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
276 .await
277 .unwrap();
278 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
279 assert!(
280 json["error"]
281 .as_str()
282 .unwrap()
283 .contains("Unsupported chain")
284 );
285 }
286
287 #[tokio::test]
288 async fn test_handle_export_label_not_found() {
289 use crate::chains::DefaultClientFactory;
290 use crate::config::Config;
291 use crate::web::AppState;
292 use axum::extract::State;
293 use axum::http::StatusCode;
294 use axum::response::IntoResponse;
295
296 let tmp = tempfile::tempdir().unwrap();
297 let config = Config {
298 address_book: crate::config::AddressBookConfig {
299 data_dir: Some(tmp.path().to_path_buf()),
300 },
301 ..Default::default()
302 };
303 let factory = DefaultClientFactory {
304 chains_config: config.chains.clone(),
305 };
306 let state = std::sync::Arc::new(AppState { config, factory });
307 let req = ExportRequest {
308 address: "@ghost-wallet".to_string(),
309 chain: "ethereum".to_string(),
310 format: "json".to_string(),
311 start_date: None,
312 end_date: None,
313 };
314 let response = handle(State(state), axum::Json(req)).await.into_response();
315 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
316 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
317 .await
318 .unwrap();
319 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
320 assert!(json["error"].as_str().unwrap().contains("@ghost-wallet"));
321 }
322}