| 1 | //! End-to-end: TUI action → async send task → response/token state update. |
| 2 | |
| 3 | use std::path::PathBuf; |
| 4 | |
| 5 | use cielago::app::App; |
| 6 | use cielago::model::{Collection, KeyValueRow, Method, OAuthConfig, SavedRequest}; |
| 7 | use cielago::store::AppConfig; |
| 8 | use wiremock::matchers::{method, path}; |
| 9 | use wiremock::{Mock, MockServer, ResponseTemplate}; |
| 10 | |
| 11 | fn app_with(base_url: String) -> App { |
| 12 | let mut c = Collection::new("test"); |
| 13 | c.servers = vec![base_url]; |
| 14 | let mut req = SavedRequest::blank("get thing"); |
| 15 | req.method = Method::Get; |
| 16 | req.path = "/things/1".into(); |
| 17 | req.headers |
| 18 | .push(KeyValueRow::new("X-Request-Id", "{{uuid}}", true)); |
| 19 | c.requests = vec![req]; |
| 20 | App::new( |
| 21 | c, |
| 22 | PathBuf::from("/tmp/cielago-test.json"), |
| 23 | AppConfig::default(), |
| 24 | ) |
| 25 | } |
| 26 | |
| 27 | #[tokio::test] |
| 28 | async fn send_from_app_updates_response_pane() { |
| 29 | let server = MockServer::start().await; |
| 30 | Mock::given(method("GET")) |
| 31 | .and(path("/things/1")) |
| 32 | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1}))) |
| 33 | .mount(&server) |
| 34 | .await; |
| 35 | |
| 36 | let mut app = app_with(server.uri()); |
| 37 | assert!(app.response.is_none()); |
| 38 | |
| 39 | app.send_selected(); |
| 40 | assert!(app.sending); |
| 41 | |
| 42 | let outcome = app.rx.recv().await.expect("send outcome"); |
| 43 | app.handle_outcome(outcome); |
| 44 | |
| 45 | assert!(!app.sending); |
| 46 | let resp = app.response.as_ref().expect("response recorded"); |
| 47 | assert_eq!(resp.status, 200); |
| 48 | assert!(resp.body.contains("\"id\": 1")); |
| 49 | assert!(app.status.contains("200")); |
| 50 | |
| 51 | // {{uuid}} was substituted in the outgoing header. |
| 52 | let received = server.received_requests().await.unwrap(); |
| 53 | let id = received[0] |
| 54 | .headers |
| 55 | .get("x-request-id") |
| 56 | .unwrap() |
| 57 | .to_str() |
| 58 | .unwrap(); |
| 59 | assert!(uuid::Uuid::parse_str(id).is_ok(), "got {id}"); |
| 60 | } |
| 61 | |
| 62 | #[test] |
| 63 | fn url_input_leading_verb_sets_method() { |
| 64 | let mut app = app_with("http://example.com".into()); |
| 65 | app.select_request(0); |
| 66 | |
| 67 | // A leading verb sets the method and is stripped from the path. |
| 68 | app.apply_url_input("post /pets"); |
| 69 | assert_eq!(app.collection.requests[0].method, Method::Post); |
| 70 | assert_eq!(app.collection.requests[0].path, "/pets"); |
| 71 | |
| 72 | // A bare path leaves the method untouched. |
| 73 | app.apply_url_input("/pets/1"); |
| 74 | assert_eq!(app.collection.requests[0].method, Method::Post); |
| 75 | assert_eq!(app.collection.requests[0].path, "/pets/1"); |
| 76 | |
| 77 | // A lone verb with no remainder is a path, not a method. |
| 78 | app.apply_url_input("delete"); |
| 79 | assert_eq!(app.collection.requests[0].method, Method::Post); |
| 80 | assert_eq!(app.collection.requests[0].path, "/delete"); |
| 81 | } |
| 82 | |
| 83 | #[tokio::test] |
| 84 | async fn send_with_oauth_fetches_and_caches_token() { |
| 85 | let server = MockServer::start().await; |
| 86 | Mock::given(method("POST")) |
| 87 | .and(path("/token")) |
| 88 | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ |
| 89 | "access_token": "cached-tok", |
| 90 | "expires_in": 3600 |
| 91 | }))) |
| 92 | .expect(1) // fetched once, then cached |
| 93 | .mount(&server) |
| 94 | .await; |
| 95 | Mock::given(method("GET")) |
| 96 | .and(path("/things/1")) |
| 97 | .respond_with(ResponseTemplate::new(200)) |
| 98 | .expect(2) |
| 99 | .mount(&server) |
| 100 | .await; |
| 101 | |
| 102 | let mut app = app_with(server.uri()); |
| 103 | app.collection.auth = Some(OAuthConfig { |
| 104 | token_url: format!("{}/token", server.uri()), |
| 105 | client_id: "id".into(), |
| 106 | client_secret: "secret".into(), |
| 107 | scopes: vec![], |
| 108 | auth_style: cielago::model::AuthStyle::Basic, |
| 109 | ..Default::default() |
| 110 | }); |
| 111 | |
| 112 | // First send: fetches a token. |
| 113 | app.send_selected(); |
| 114 | let outcome = app.rx.recv().await.unwrap(); |
| 115 | app.handle_outcome(outcome); |
| 116 | assert_eq!(app.response.as_ref().unwrap().status, 200); |
| 117 | assert!(app.token.is_some()); |
| 118 | |
| 119 | // Second send: reuses the cached token (token endpoint expect(1)). |
| 120 | app.send_selected(); |
| 121 | let outcome = app.rx.recv().await.unwrap(); |
| 122 | app.handle_outcome(outcome); |
| 123 | assert_eq!(app.response.as_ref().unwrap().status, 200); |
| 124 | |
| 125 | // Both API calls carried the bearer token. |
| 126 | let received = server.received_requests().await.unwrap(); |
| 127 | let api_calls: Vec<_> = received |
| 128 | .iter() |
| 129 | .filter(|r| r.url.path() == "/things/1") |
| 130 | .collect(); |
| 131 | assert_eq!(api_calls.len(), 2); |
| 132 | for r in api_calls { |
| 133 | assert_eq!( |
| 134 | r.headers.get("authorization").unwrap().to_str().unwrap(), |
| 135 | "Bearer cached-tok" |
| 136 | ); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | #[tokio::test] |
| 141 | async fn send_without_server_shows_status_error() { |
| 142 | let mut app = app_with("".into()); |
| 143 | app.collection.servers.clear(); |
| 144 | app.send_selected(); |
| 145 | assert!(!app.sending); |
| 146 | assert!(app.status.contains("No server configured")); |
| 147 | } |
| 148 | |
| 149 | // After naming a new request, the flow chains into the URL edit prefilled |
| 150 | // with the default method so `GET ` is shown awaiting a route. |
| 151 | #[test] |
| 152 | fn new_request_chains_to_url_prefilled_with_method() { |
| 153 | let mut app = app_with("http://example.com".into()); |
| 154 | app.start_edit(cielago::app::EditTarget::NewRequest); |
| 155 | app.input.set("make thing"); |
| 156 | app.commit_edit(); |
| 157 | assert_eq!(app.editing, Some(cielago::app::EditTarget::Url)); |
| 158 | assert_eq!(app.input.buf, "GET "); |
| 159 | } |