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 factory = DefaultClientFactory {
222 chains_config: config.chains.clone(),
223 };
224 let state = std::sync::Arc::new(AppState { config, factory });
225 let response = handle_list(State(state)).await.into_response();
226 let status = response.status();
227 assert!(status.is_success() || status.is_server_error());
228 }
229
230 #[tokio::test]
231 async fn test_handle_address_book_add_direct() {
232 use crate::chains::DefaultClientFactory;
233 use crate::config::Config;
234 use crate::web::AppState;
235 use axum::extract::State;
236 use axum::response::IntoResponse;
237
238 let config = Config::default();
239 let factory = DefaultClientFactory {
240 chains_config: config.chains.clone(),
241 };
242 let state = std::sync::Arc::new(AppState { config, factory });
243 let req = AddAddressBookRequest {
244 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
245 chain: "ethereum".to_string(),
246 label: Some("Test".to_string()),
247 tags: vec!["test".to_string()],
248 };
249 let response = handle_add(State(state), axum::Json(req))
250 .await
251 .into_response();
252 let status = response.status();
253 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
254 }
255
256 #[test]
257 fn test_deserialize_remove_request() {
258 let json = serde_json::json!({
259 "address": "0x1234567890123456789012345678901234567890"
260 });
261 let req: RemoveAddressBookRequest = serde_json::from_value(json).unwrap();
262 assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
263 }
264
265 #[tokio::test]
266 async fn test_handle_address_book_remove_direct() {
267 use crate::chains::DefaultClientFactory;
268 use crate::config::Config;
269 use crate::web::AppState;
270 use axum::extract::State;
271 use axum::response::IntoResponse;
272
273 let config = Config::default();
274 let factory = DefaultClientFactory {
275 chains_config: config.chains.clone(),
276 };
277 let state = std::sync::Arc::new(AppState { config, factory });
278 let req = RemoveAddressBookRequest {
279 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
280 };
281 let response = handle_remove(State(state), axum::Json(req))
282 .await
283 .into_response();
284 let status = response.status();
285 assert!(
287 status.is_success()
288 || status == axum::http::StatusCode::NOT_FOUND
289 || status.is_server_error()
290 );
291 }
292
293 #[tokio::test]
294 async fn test_handle_address_book_remove_nonexistent() {
295 use crate::chains::DefaultClientFactory;
296 use crate::config::Config;
297 use crate::web::AppState;
298 use axum::extract::State;
299 use axum::response::IntoResponse;
300
301 let config = Config::default();
302 let factory = DefaultClientFactory {
303 chains_config: config.chains.clone(),
304 };
305 let state = std::sync::Arc::new(AppState { config, factory });
306 let req = RemoveAddressBookRequest {
307 address: "0x000000000000000000000000000000000000dead".to_string(),
308 };
309 let response = handle_remove(State(state), axum::Json(req))
310 .await
311 .into_response();
312 assert!(
314 response.status() == axum::http::StatusCode::NOT_FOUND
315 || response.status().is_server_error()
316 || response.status().is_success()
317 );
318 }
319
320 #[tokio::test]
321 async fn test_handle_address_book_list_json_structure() {
322 use crate::chains::DefaultClientFactory;
323 use crate::config::Config;
324 use axum::body;
325 use axum::extract::State;
326
327 let config = Config::default();
328 let factory = DefaultClientFactory {
329 chains_config: config.chains.clone(),
330 };
331 let state = std::sync::Arc::new(AppState { config, factory });
332 let response = handle_list(State(state)).await.into_response();
333 if response.status().is_success() {
334 let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
335 .await
336 .unwrap();
337 let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
338 assert!(json.get("addresses").is_some());
339 }
340 }
341
342 #[tokio::test]
343 async fn test_handle_address_book_add_duplicate_returns_bad_request() {
344 use crate::chains::DefaultClientFactory;
345 use crate::config::Config;
346 use crate::web::AppState;
347 use axum::extract::State;
348 use axum::http::StatusCode;
349 use axum::response::IntoResponse;
350
351 let tmp_dir = tempfile::tempdir().unwrap();
352 let data_dir = tmp_dir.path().to_path_buf();
353 let mut config = Config::default();
354 config.address_book.data_dir = Some(data_dir.clone());
355
356 let factory = DefaultClientFactory {
357 chains_config: config.chains.clone(),
358 };
359 let state = std::sync::Arc::new(AppState { config, factory });
360
361 let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string();
362 let req1 = AddAddressBookRequest {
363 address: addr.clone(),
364 chain: "ethereum".to_string(),
365 label: Some("First".to_string()),
366 tags: vec![],
367 };
368 let r1 = handle_add(State(state.clone()), axum::Json(req1))
369 .await
370 .into_response();
371 if !r1.status().is_success() {
372 return;
373 }
374
375 let req2 = AddAddressBookRequest {
376 address: addr,
377 chain: "ethereum".to_string(),
378 label: Some("Duplicate".to_string()),
379 tags: vec![],
380 };
381 let r2 = handle_add(State(state), axum::Json(req2))
382 .await
383 .into_response();
384 assert_eq!(r2.status(), StatusCode::BAD_REQUEST);
385 let body = axum::body::to_bytes(r2.into_body(), 1_000_000)
386 .await
387 .unwrap();
388 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
389 assert!(
390 json["error"]
391 .as_str()
392 .unwrap()
393 .to_lowercase()
394 .contains("already")
395 );
396 }
397
398 #[tokio::test]
399 async fn test_handle_address_book_list_corrupt_file_returns_500() {
400 use crate::chains::DefaultClientFactory;
401 use crate::config::Config;
402 use crate::web::AppState;
403 use axum::extract::State;
404 use axum::http::StatusCode;
405
406 let tmp_dir = tempfile::tempdir().unwrap();
407 let yaml_path = tmp_dir.path().join("address_book.yaml");
408 std::fs::write(&yaml_path, "{{{ invalid yaml").unwrap();
409
410 let mut config = Config::default();
411 config.address_book.data_dir = Some(tmp_dir.path().to_path_buf());
412 let factory = DefaultClientFactory {
413 chains_config: config.chains.clone(),
414 };
415 let state = std::sync::Arc::new(AppState { config, factory });
416 let response = handle_list(State(state)).await.into_response();
417 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
418 }
419}