1use crate::cli::address_book::{AddressBook, WatchedAddress};
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
12pub async fn handle_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
14 let data_dir = state.config.data_dir();
15 match AddressBook::load(&data_dir) {
16 Ok(address_book) => Json(serde_json::json!({
17 "addresses": address_book.addresses,
18 }))
19 .into_response(),
20 Err(e) => (
21 StatusCode::INTERNAL_SERVER_ERROR,
22 Json(serde_json::json!({ "error": e.to_string() })),
23 )
24 .into_response(),
25 }
26}
27
28#[derive(Debug, Deserialize)]
30pub struct AddAddressBookRequest {
31 pub address: String,
33 #[serde(default = "default_chain")]
35 pub chain: String,
36 pub label: Option<String>,
38 #[serde(default)]
40 pub tags: Vec<String>,
41}
42
43fn default_chain() -> String {
44 "ethereum".to_string()
45}
46
47pub async fn handle_add(
49 State(state): State<Arc<AppState>>,
50 Json(req): Json<AddAddressBookRequest>,
51) -> impl IntoResponse {
52 let data_dir = state.config.data_dir();
53 let mut address_book = match AddressBook::load(&data_dir) {
54 Ok(ab) => ab,
55 Err(e) => {
56 return (
57 StatusCode::INTERNAL_SERVER_ERROR,
58 Json(serde_json::json!({ "error": e.to_string() })),
59 )
60 .into_response();
61 }
62 };
63
64 let watched = WatchedAddress {
65 address: req.address.clone(),
66 label: req.label,
67 chain: req.chain,
68 tags: req.tags,
69 added_at: chrono::Utc::now().timestamp() as u64,
70 };
71
72 match address_book.add_address(watched) {
73 Ok(_) => {
74 let data_dir_buf = data_dir.to_path_buf();
75 if let Err(e) = address_book.save(&data_dir_buf) {
76 return (
77 StatusCode::INTERNAL_SERVER_ERROR,
78 Json(serde_json::json!({ "error": e.to_string() })),
79 )
80 .into_response();
81 }
82 Json(serde_json::json!({
83 "status": "added",
84 "address": req.address,
85 "addresses": address_book.addresses,
86 }))
87 .into_response()
88 }
89 Err(e) => (
90 StatusCode::BAD_REQUEST,
91 Json(serde_json::json!({ "error": e.to_string() })),
92 )
93 .into_response(),
94 }
95}
96
97#[derive(Debug, Deserialize)]
99pub struct RemoveAddressBookRequest {
100 pub address: String,
102}
103
104pub async fn handle_remove(
106 State(state): State<Arc<AppState>>,
107 Json(req): Json<RemoveAddressBookRequest>,
108) -> impl IntoResponse {
109 let data_dir = state.config.data_dir();
110 let mut address_book = match AddressBook::load(&data_dir) {
111 Ok(ab) => ab,
112 Err(e) => {
113 return (
114 StatusCode::INTERNAL_SERVER_ERROR,
115 Json(serde_json::json!({ "error": e.to_string() })),
116 )
117 .into_response();
118 }
119 };
120
121 match address_book.remove_address(&req.address) {
122 Ok(true) => {
123 let data_dir_buf = data_dir.to_path_buf();
124 if let Err(e) = address_book.save(&data_dir_buf) {
125 return (
126 StatusCode::INTERNAL_SERVER_ERROR,
127 Json(serde_json::json!({ "error": e.to_string() })),
128 )
129 .into_response();
130 }
131 Json(serde_json::json!({
132 "status": "removed",
133 "address": req.address,
134 "addresses": address_book.addresses,
135 }))
136 .into_response()
137 }
138 Ok(false) => (
139 StatusCode::NOT_FOUND,
140 Json(serde_json::json!({ "error": format!("Address '{}' not found in address book", req.address) })),
141 )
142 .into_response(),
143 Err(e) => (
144 StatusCode::INTERNAL_SERVER_ERROR,
145 Json(serde_json::json!({ "error": e.to_string() })),
146 )
147 .into_response(),
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn test_deserialize_full() {
157 let json = serde_json::json!({
158 "address": "0x1234567890123456789012345678901234567890",
159 "chain": "polygon",
160 "label": "My Wallet",
161 "tags": ["defi", "nft"]
162 });
163 let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
164 assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
165 assert_eq!(req.chain, "polygon");
166 assert_eq!(req.label, Some("My Wallet".to_string()));
167 assert_eq!(req.tags.len(), 2);
168 assert_eq!(req.tags[0], "defi");
169 assert_eq!(req.tags[1], "nft");
170 }
171
172 #[test]
173 fn test_deserialize_minimal() {
174 let json = serde_json::json!({
175 "address": "0x1234567890123456789012345678901234567890"
176 });
177 let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
178 assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
179 assert_eq!(req.chain, "ethereum");
180 assert_eq!(req.label, None);
181 assert_eq!(req.tags.len(), 0);
182 }
183
184 #[test]
185 fn test_default_chain() {
186 assert_eq!(default_chain(), "ethereum");
187 }
188
189 #[test]
190 fn test_with_tags() {
191 let json = serde_json::json!({
192 "address": "0x1234567890123456789012345678901234567890",
193 "tags": ["tag1", "tag2", "tag3"]
194 });
195 let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
196 assert_eq!(req.tags.len(), 3);
197 assert_eq!(req.tags[0], "tag1");
198 assert_eq!(req.tags[1], "tag2");
199 assert_eq!(req.tags[2], "tag3");
200 }
201
202 #[test]
203 fn test_with_label() {
204 let json = serde_json::json!({
205 "address": "0x1234567890123456789012345678901234567890",
206 "label": "Test Label"
207 });
208 let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
209 assert_eq!(req.label, Some("Test Label".to_string()));
210 }
211
212 #[tokio::test]
213 async fn test_handle_address_book_list_direct() {
214 use crate::chains::DefaultClientFactory;
215 use crate::config::Config;
216 use crate::web::AppState;
217 use axum::extract::State;
218 use axum::response::IntoResponse;
219
220 let config = Config::default();
221 let http: std::sync::Arc<dyn crate::http::HttpClient> =
222 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
223 let factory = DefaultClientFactory {
224 chains_config: config.chains.clone(),
225 http,
226 };
227 let state = std::sync::Arc::new(AppState { config, factory });
228 let response = handle_list(State(state)).await.into_response();
229 let status = response.status();
230 assert!(status.is_success() || status.is_server_error());
231 }
232
233 #[tokio::test]
234 async fn test_handle_address_book_add_direct() {
235 use crate::chains::DefaultClientFactory;
236 use crate::config::Config;
237 use crate::web::AppState;
238 use axum::extract::State;
239 use axum::response::IntoResponse;
240
241 let config = Config::default();
242 let http: std::sync::Arc<dyn crate::http::HttpClient> =
243 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
244 let factory = DefaultClientFactory {
245 chains_config: config.chains.clone(),
246 http,
247 };
248 let state = std::sync::Arc::new(AppState { config, factory });
249 let req = AddAddressBookRequest {
250 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
251 chain: "ethereum".to_string(),
252 label: Some("Test".to_string()),
253 tags: vec!["test".to_string()],
254 };
255 let response = handle_add(State(state), axum::Json(req))
256 .await
257 .into_response();
258 let status = response.status();
259 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
260 }
261
262 #[test]
263 fn test_deserialize_remove_request() {
264 let json = serde_json::json!({
265 "address": "0x1234567890123456789012345678901234567890"
266 });
267 let req: RemoveAddressBookRequest = serde_json::from_value(json).unwrap();
268 assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
269 }
270
271 #[tokio::test]
272 async fn test_handle_address_book_remove_direct() {
273 use crate::chains::DefaultClientFactory;
274 use crate::config::Config;
275 use crate::web::AppState;
276 use axum::extract::State;
277 use axum::response::IntoResponse;
278
279 let config = Config::default();
280 let http: std::sync::Arc<dyn crate::http::HttpClient> =
281 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
282 let factory = DefaultClientFactory {
283 chains_config: config.chains.clone(),
284 http,
285 };
286 let state = std::sync::Arc::new(AppState { config, factory });
287 let req = RemoveAddressBookRequest {
288 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
289 };
290 let response = handle_remove(State(state), axum::Json(req))
291 .await
292 .into_response();
293 let status = response.status();
294 assert!(
296 status.is_success()
297 || status == axum::http::StatusCode::NOT_FOUND
298 || status.is_server_error()
299 );
300 }
301
302 #[tokio::test]
303 async fn test_handle_address_book_remove_nonexistent() {
304 use crate::chains::DefaultClientFactory;
305 use crate::config::Config;
306 use crate::web::AppState;
307 use axum::extract::State;
308 use axum::response::IntoResponse;
309
310 let config = Config::default();
311 let http: std::sync::Arc<dyn crate::http::HttpClient> =
312 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
313 let factory = DefaultClientFactory {
314 chains_config: config.chains.clone(),
315 http,
316 };
317 let state = std::sync::Arc::new(AppState { config, factory });
318 let req = RemoveAddressBookRequest {
319 address: "0x000000000000000000000000000000000000dead".to_string(),
320 };
321 let response = handle_remove(State(state), axum::Json(req))
322 .await
323 .into_response();
324 assert!(
326 response.status() == axum::http::StatusCode::NOT_FOUND
327 || response.status().is_server_error()
328 || response.status().is_success()
329 );
330 }
331
332 #[tokio::test]
333 async fn test_handle_address_book_list_json_structure() {
334 use crate::chains::DefaultClientFactory;
335 use crate::config::Config;
336 use axum::body;
337 use axum::extract::State;
338
339 let config = Config::default();
340 let http: std::sync::Arc<dyn crate::http::HttpClient> =
341 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
342 let factory = DefaultClientFactory {
343 chains_config: config.chains.clone(),
344 http,
345 };
346 let state = std::sync::Arc::new(AppState { config, factory });
347 let response = handle_list(State(state)).await.into_response();
348 if response.status().is_success() {
349 let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
350 .await
351 .unwrap();
352 let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
353 assert!(json.get("addresses").is_some());
354 }
355 }
356
357 #[tokio::test]
358 async fn test_handle_address_book_add_duplicate_returns_bad_request() {
359 use crate::chains::DefaultClientFactory;
360 use crate::config::Config;
361 use crate::web::AppState;
362 use axum::extract::State;
363 use axum::http::StatusCode;
364 use axum::response::IntoResponse;
365
366 let tmp_dir = tempfile::tempdir().unwrap();
367 let data_dir = tmp_dir.path().to_path_buf();
368 let mut config = Config::default();
369 config.address_book.data_dir = Some(data_dir.clone());
370
371 let http: std::sync::Arc<dyn crate::http::HttpClient> =
372 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
373 let factory = DefaultClientFactory {
374 chains_config: config.chains.clone(),
375 http,
376 };
377 let state = std::sync::Arc::new(AppState { config, factory });
378
379 let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string();
380 let req1 = AddAddressBookRequest {
381 address: addr.clone(),
382 chain: "ethereum".to_string(),
383 label: Some("First".to_string()),
384 tags: vec![],
385 };
386 let r1 = handle_add(State(state.clone()), axum::Json(req1))
387 .await
388 .into_response();
389 if !r1.status().is_success() {
390 return;
391 }
392
393 let req2 = AddAddressBookRequest {
394 address: addr,
395 chain: "ethereum".to_string(),
396 label: Some("Duplicate".to_string()),
397 tags: vec![],
398 };
399 let r2 = handle_add(State(state), axum::Json(req2))
400 .await
401 .into_response();
402 assert_eq!(r2.status(), StatusCode::BAD_REQUEST);
403 let body = axum::body::to_bytes(r2.into_body(), 1_000_000)
404 .await
405 .unwrap();
406 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
407 assert!(
408 json["error"]
409 .as_str()
410 .unwrap()
411 .to_lowercase()
412 .contains("already")
413 );
414 }
415
416 #[tokio::test]
417 async fn test_handle_address_book_list_corrupt_file_returns_500() {
418 use crate::chains::DefaultClientFactory;
419 use crate::config::Config;
420 use crate::web::AppState;
421 use axum::extract::State;
422 use axum::http::StatusCode;
423
424 let tmp_dir = tempfile::tempdir().unwrap();
425 let yaml_path = tmp_dir.path().join("address_book.yaml");
426 std::fs::write(&yaml_path, "{{{ invalid yaml").unwrap();
427
428 let mut config = Config::default();
429 config.address_book.data_dir = Some(tmp_dir.path().to_path_buf());
430 let http: std::sync::Arc<dyn crate::http::HttpClient> =
431 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
432 let factory = DefaultClientFactory {
433 chains_config: config.chains.clone(),
434 http,
435 };
436 let state = std::sync::Arc::new(AppState { config, factory });
437 let response = handle_list(State(state)).await.into_response();
438 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
439 }
440}