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