ubi_rs/commands/
project.rs

1use crate::client::HTTPClient;
2use crate::errors::UbiClientError;
3use crate::{make_json_request, make_request};
4use serde::Deserialize;
5use serde::Serialize;
6use std::sync::Arc;
7
8#[derive(Debug, Serialize, Deserialize, Default)]
9pub struct ProjectList {
10    pub count: u32,
11    pub items: Vec<UserProject>,
12}
13
14#[derive(Debug, Serialize, Deserialize, Default)]
15pub struct UserProject {
16    pub credit: f64,
17    pub discount: u32,
18    pub id: String,
19    pub name: String,
20}
21
22pub struct Project {
23    http_client: Arc<HTTPClient>,
24}
25impl Project {
26    pub fn new(http_client: Arc<HTTPClient>) -> Self {
27        Project { http_client }
28    }
29
30    /// List all projects visible to the logged in user.
31    /// https://api.ubicloud.com/project
32    ///
33    pub async fn list_projects(&self) -> Result<ProjectList, UbiClientError> {
34        let url = "project";
35        let resp = make_request!(self, reqwest::Method::GET, url)?;
36        Ok(resp.json().await?)
37    }
38
39    /// Create a new project
40    /// https://api.ubicloud.com/project
41    /// # Arguments:
42    /// * `name` - The name of the project to create.
43    pub async fn create_project(&self, name: &str) -> Result<UserProject, UbiClientError> {
44        let url = "project";
45        let body = serde_json::json!({
46            "name": name,
47        });
48        let query = [(); 0];
49        make_json_request!(self, reqwest::Method::POST, url, body, query, UserProject)
50    }
51
52    /// Delete a project
53    /// https://api.ubicloud.com/project/{project_id}
54    /// # Arguments:
55    /// * `project_id` - The ID of the project to delete.
56    pub async fn delete_project(&self, project_id: &str) -> Result<(), UbiClientError> {
57        let url = format!("project/{}", project_id);
58        let _resp = make_request!(self, reqwest::Method::DELETE, &url)?;
59        Ok(())
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::client::HTTPClient;
67    use mockito::{Matcher, Server};
68    use std::sync::Arc;
69
70    fn create_test_client(server_url: &str) -> Project {
71        let reqwest_client = reqwest::Client::new();
72        let http_client = HTTPClient::new(server_url, reqwest_client, "v1");
73        Project::new(Arc::new(http_client))
74    }
75
76    #[tokio::test]
77    async fn test_list_projects_success() {
78        let mut server = Server::new_async().await;
79        let mock_response = ProjectList {
80            count: 2,
81            items: vec![
82                UserProject {
83                    credit: 100.50,
84                    discount: 10,
85                    id: "project-1".to_string(),
86                    name: "Test Project 1".to_string(),
87                },
88                UserProject {
89                    credit: 250.75,
90                    discount: 15,
91                    id: "project-2".to_string(),
92                    name: "Test Project 2".to_string(),
93                },
94            ],
95        };
96
97        let mock = server
98            .mock("GET", "/v1/project")
99            .with_status(200)
100            .with_header("content-type", "application/json")
101            .with_body(serde_json::to_string(&mock_response).unwrap())
102            .create_async()
103            .await;
104
105        let client = create_test_client(&server.url());
106        let result = client.list_projects().await.unwrap();
107
108        mock.assert_async().await;
109        assert_eq!(result.count, 2);
110        assert_eq!(result.items.len(), 2);
111        assert_eq!(result.items[0].name, "Test Project 1");
112        assert_eq!(result.items[0].id, "project-1");
113        assert_eq!(result.items[0].credit, 100.50);
114        assert_eq!(result.items[0].discount, 10);
115        assert_eq!(result.items[1].name, "Test Project 2");
116        assert_eq!(result.items[1].id, "project-2");
117        assert_eq!(result.items[1].credit, 250.75);
118        assert_eq!(result.items[1].discount, 15);
119    }
120
121    #[tokio::test]
122    async fn test_list_projects_empty() {
123        let mut server = Server::new_async().await;
124        let mock_response = ProjectList {
125            count: 0,
126            items: vec![],
127        };
128
129        let mock = server
130            .mock("GET", "/v1/project")
131            .with_status(200)
132            .with_header("content-type", "application/json")
133            .with_body(serde_json::to_string(&mock_response).unwrap())
134            .create_async()
135            .await;
136
137        let client = create_test_client(&server.url());
138        let result = client.list_projects().await.unwrap();
139
140        mock.assert_async().await;
141        assert_eq!(result.count, 0);
142        assert_eq!(result.items.len(), 0);
143    }
144
145    #[tokio::test]
146    async fn test_create_project_success() {
147        let mut server = Server::new_async().await;
148        let mock_response = UserProject {
149            credit: 0.0,
150            discount: 0,
151            id: "new-project-123".to_string(),
152            name: "My New Project".to_string(),
153        };
154
155        let mock = server
156            .mock("POST", "/v1/project")
157            .match_body(Matcher::Json(serde_json::json!({
158                "name": "My New Project"
159            })))
160            .with_status(201)
161            .with_header("content-type", "application/json")
162            .with_body(serde_json::to_string(&mock_response).unwrap())
163            .create_async()
164            .await;
165
166        let client = create_test_client(&server.url());
167        let result = client.create_project("My New Project").await.unwrap();
168
169        mock.assert_async().await;
170        assert_eq!(result.name, "My New Project");
171        assert_eq!(result.id, "new-project-123");
172        assert_eq!(result.credit, 0.0);
173        assert_eq!(result.discount, 0);
174    }
175
176    #[tokio::test]
177    async fn test_create_project_with_special_characters() {
178        let mut server = Server::new_async().await;
179        let project_name = "Project with @special #characters & symbols!";
180        let mock_response = UserProject {
181            credit: 50.0,
182            discount: 5,
183            id: "special-project-456".to_string(),
184            name: project_name.to_string(),
185        };
186
187        let mock = server
188            .mock("POST", "/v1/project")
189            .match_body(Matcher::Json(serde_json::json!({
190                "name": project_name
191            })))
192            .with_status(201)
193            .with_header("content-type", "application/json")
194            .with_body(serde_json::to_string(&mock_response).unwrap())
195            .create_async()
196            .await;
197
198        let client = create_test_client(&server.url());
199        let result = client.create_project(project_name).await.unwrap();
200
201        mock.assert_async().await;
202        assert_eq!(result.name, project_name);
203        assert_eq!(result.id, "special-project-456");
204    }
205
206    #[tokio::test]
207    async fn test_delete_project_success() {
208        let mut server = Server::new_async().await;
209
210        let mock = server
211            .mock("DELETE", "/v1/project/test-project-123")
212            .with_status(204)
213            .create_async()
214            .await;
215
216        let client = create_test_client(&server.url());
217        let result = client.delete_project("test-project-123").await;
218
219        mock.assert_async().await;
220        assert!(result.is_ok());
221    }
222
223    #[tokio::test]
224    async fn test_delete_project_with_special_id() {
225        let mut server = Server::new_async().await;
226        let project_id = "project-with-dashes-and-numbers-123";
227
228        let mock = server
229            .mock("DELETE", format!("/v1/project/{}", project_id).as_str())
230            .with_status(204)
231            .create_async()
232            .await;
233
234        let client = create_test_client(&server.url());
235        let result = client.delete_project(project_id).await;
236
237        mock.assert_async().await;
238        assert!(result.is_ok());
239    }
240
241    #[tokio::test]
242    async fn test_list_projects_api_error() {
243        let mut server = Server::new_async().await;
244        let error_response = serde_json::json!({
245            "error": {
246                "type": "Unauthorized",
247                "message": "Invalid authentication token",
248                "details": "The provided token is expired or invalid"
249            }
250        });
251
252        let mock = server
253            .mock("GET", "/v1/project")
254            .with_status(401)
255            .with_header("content-type", "application/json")
256            .with_body(error_response.to_string())
257            .create_async()
258            .await;
259
260        let client = create_test_client(&server.url());
261        let result = client.list_projects().await;
262
263        mock.assert_async().await;
264        assert!(result.is_err());
265
266        if let Err(UbiClientError::APIResponseError {
267            etype,
268            message,
269            details,
270        }) = result
271        {
272            assert_eq!(etype, "Unauthorized");
273            assert_eq!(message, "Invalid authentication token");
274            assert_eq!(
275                details,
276                Some("The provided token is expired or invalid".to_string())
277            );
278        } else {
279            panic!("Expected APIResponseError");
280        }
281    }
282
283    #[tokio::test]
284    async fn test_create_project_validation_error() {
285        let mut server = Server::new_async().await;
286        let error_response = serde_json::json!({
287            "error": {
288                "type": "ValidationError",
289                "message": "Project name is required",
290                "details": "The project name cannot be empty"
291            }
292        });
293
294        let mock = server
295            .mock("POST", "/v1/project")
296            .match_body(Matcher::Json(serde_json::json!({
297                "name": ""
298            })))
299            .with_status(400)
300            .with_header("content-type", "application/json")
301            .with_body(error_response.to_string())
302            .create_async()
303            .await;
304
305        let client = create_test_client(&server.url());
306        let result = client.create_project("").await;
307
308        mock.assert_async().await;
309        assert!(result.is_err());
310
311        if let Err(UbiClientError::APIResponseError { etype, message, .. }) = result {
312            assert_eq!(etype, "ValidationError");
313            assert_eq!(message, "Project name is required");
314        } else {
315            panic!("Expected APIResponseError");
316        }
317    }
318
319    #[tokio::test]
320    async fn test_create_project_conflict_error() {
321        let mut server = Server::new_async().await;
322        let error_response = serde_json::json!({
323            "error": {
324                "type": "Conflict",
325                "message": "Project with this name already exists",
326                "details": null
327            }
328        });
329
330        let mock = server
331            .mock("POST", "/v1/project")
332            .match_body(Matcher::Json(serde_json::json!({
333                "name": "Existing Project"
334            })))
335            .with_status(409)
336            .with_header("content-type", "application/json")
337            .with_body(error_response.to_string())
338            .create_async()
339            .await;
340
341        let client = create_test_client(&server.url());
342        let result = client.create_project("Existing Project").await;
343
344        mock.assert_async().await;
345        assert!(result.is_err());
346
347        if let Err(UbiClientError::APIResponseError { etype, message, .. }) = result {
348            assert_eq!(etype, "Conflict");
349            assert_eq!(message, "Project with this name already exists");
350        } else {
351            panic!("Expected APIResponseError");
352        }
353    }
354
355    #[tokio::test]
356    async fn test_delete_project_not_found() {
357        let mut server = Server::new_async().await;
358        let error_response = serde_json::json!({
359            "error": {
360                "type": "NotFound",
361                "message": "Project not found",
362                "details": "The specified project does not exist or you don't have permission to delete it"
363            }
364        });
365
366        let mock = server
367            .mock("DELETE", "/v1/project/nonexistent-project")
368            .with_status(404)
369            .with_header("content-type", "application/json")
370            .with_body(error_response.to_string())
371            .create_async()
372            .await;
373
374        let client = create_test_client(&server.url());
375        let result = client.delete_project("nonexistent-project").await;
376
377        mock.assert_async().await;
378        assert!(result.is_err());
379
380        if let Err(UbiClientError::APIResponseError { etype, message, .. }) = result {
381            assert_eq!(etype, "NotFound");
382            assert_eq!(message, "Project not found");
383        } else {
384            panic!("Expected APIResponseError");
385        }
386    }
387
388    #[tokio::test]
389    async fn test_delete_project_forbidden() {
390        let mut server = Server::new_async().await;
391        let error_response = serde_json::json!({
392            "error": {
393                "type": "Forbidden",
394                "message": "Insufficient permissions to delete project",
395                "details": "Only project owners can delete projects"
396            }
397        });
398
399        let mock = server
400            .mock("DELETE", "/v1/project/restricted-project")
401            .with_status(403)
402            .with_header("content-type", "application/json")
403            .with_body(error_response.to_string())
404            .create_async()
405            .await;
406
407        let client = create_test_client(&server.url());
408        let result = client.delete_project("restricted-project").await;
409
410        mock.assert_async().await;
411        assert!(result.is_err());
412
413        if let Err(UbiClientError::APIResponseError { etype, message, .. }) = result {
414            assert_eq!(etype, "Forbidden");
415            assert_eq!(message, "Insufficient permissions to delete project");
416        } else {
417            panic!("Expected APIResponseError");
418        }
419    }
420
421    #[test]
422    fn test_project_list_default() {
423        let project_list = ProjectList::default();
424        assert_eq!(project_list.count, 0);
425        assert_eq!(project_list.items.len(), 0);
426    }
427
428    #[test]
429    fn test_user_project_default() {
430        let user_project = UserProject::default();
431        assert_eq!(user_project.credit, 0.0);
432        assert_eq!(user_project.discount, 0);
433        assert_eq!(user_project.id, "");
434        assert_eq!(user_project.name, "");
435    }
436
437    #[test]
438    fn test_user_project_serialization() {
439        let project = UserProject {
440            credit: 123.45,
441            discount: 20,
442            id: "test-project-789".to_string(),
443            name: "Serialization Test Project".to_string(),
444        };
445
446        let serialized = serde_json::to_string(&project).unwrap();
447        let deserialized: UserProject = serde_json::from_str(&serialized).unwrap();
448
449        assert_eq!(project.credit, deserialized.credit);
450        assert_eq!(project.discount, deserialized.discount);
451        assert_eq!(project.id, deserialized.id);
452        assert_eq!(project.name, deserialized.name);
453    }
454
455    #[test]
456    fn test_project_list_serialization() {
457        let project_list = ProjectList {
458            count: 1,
459            items: vec![UserProject {
460                credit: 99.99,
461                discount: 5,
462                id: "serialize-test".to_string(),
463                name: "Serialize Test".to_string(),
464            }],
465        };
466
467        let serialized = serde_json::to_string(&project_list).unwrap();
468        let deserialized: ProjectList = serde_json::from_str(&serialized).unwrap();
469
470        assert_eq!(project_list.count, deserialized.count);
471        assert_eq!(project_list.items.len(), deserialized.items.len());
472        assert_eq!(project_list.items[0].name, deserialized.items[0].name);
473    }
474
475    #[test]
476    fn test_project_new() {
477        let reqwest_client = reqwest::Client::new();
478        let http_client = HTTPClient::new("https://api.example.com", reqwest_client, "v1");
479        let project_client = Project::new(Arc::new(http_client));
480
481        // Just verify the client was created successfully
482        // We can't directly test the http_client field since it's private
483        assert!(std::ptr::addr_of!(project_client) as usize != 0);
484    }
485}