Skip to main content

scope_web/api/
address_book.rs

1//! Address book management API handlers.
2
3use 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
13/// GET /api/address-book/list — List address book entries.
14pub 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/// Request body for adding an address book entry.
26#[derive(Debug, Deserialize)]
27pub struct AddAddressBookRequest {
28    /// Blockchain address.
29    pub address: String,
30    /// Chain (default: "ethereum").
31    #[serde(default = "super::default_chain")]
32    pub chain: String,
33    /// Optional label.
34    pub label: Option<String>,
35    /// Optional tags.
36    #[serde(default)]
37    pub tags: Vec<String>,
38}
39
40/// POST /api/address-book/add — Add an address to the address book.
41pub 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/// Request body for removing an address book entry.
77#[derive(Debug, Deserialize)]
78pub struct RemoveAddressBookRequest {
79    /// Blockchain address to remove.
80    pub address: String,
81}
82
83/// POST /api/address-book/remove — Remove an address from the address book.
84pub 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        // 200 (removed), 404 (not found), or 500 (load/save error)
262        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        // Should return 404 (not found) or 500 (load error)
292        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}