diff --git a/src/github/actions.rs b/src/github/actions.rs index 22055727..dc4328f5 100644 --- a/src/github/actions.rs +++ b/src/github/actions.rs @@ -87,4 +87,75 @@ mod tests { assert_eq!(with["inputs"]["env"], "prod"); assert!(dispatch_body("main", None).get("inputs").is_none()); } + + use crate::github::test_support::{MockResponse, MockServer}; + + fn repo() -> RepoId { + RepoId { + owner: "o".into(), + repo: "r".into(), + } + } + + #[test] + fn list_runs_unwraps_envelope_without_branch() { + let server = MockServer::start(vec![MockResponse::json( + 200, + r#"{"workflow_runs":[{"id":1,"status":"completed","conclusion":"success","html_url":"u"}]}"#, + )]); + let client = server.client(None); + let runs = list_runs(&client, &repo(), None).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!( + server.requests()[0].path, + "/repos/o/r/actions/runs?per_page=100&page=1" + ); + } + + #[test] + fn list_runs_appends_branch_query() { + let server = MockServer::start(vec![MockResponse::json(200, r#"{"workflow_runs":[]}"#)]); + let client = server.client(None); + list_runs(&client, &repo(), Some("feat/x")).unwrap(); + assert_eq!( + server.requests()[0].path, + "/repos/o/r/actions/runs?branch=feat/x&per_page=100&page=1" + ); + } + + #[test] + fn get_run_fetches_single_run() { + let server = MockServer::start(vec![MockResponse::json( + 200, + r#"{"id":7,"status":"completed","conclusion":"failure","html_url":"u"}"#, + )]); + let client = server.client(None); + let run = get_run(&client, &repo(), 7).unwrap(); + assert_eq!(run.conclusion.as_deref(), Some("failure")); + assert_eq!(server.requests()[0].path, "/repos/o/r/actions/runs/7"); + } + + #[test] + fn rerun_posts_to_the_rerun_endpoint() { + let server = MockServer::start(vec![MockResponse::json(201, "")]); + let client = server.client(Some("tok")); + rerun(&client, &repo(), 9).unwrap(); + let reqs = server.requests(); + assert_eq!(reqs[0].method, "POST"); + assert_eq!(reqs[0].path, "/repos/o/r/actions/runs/9/rerun"); + } + + #[test] + fn dispatch_posts_ref_to_workflow_dispatches() { + let server = MockServer::start(vec![MockResponse::json(204, "")]); + let client = server.client(Some("tok")); + dispatch(&client, &repo(), "ci.yml", "main", None).unwrap(); + let reqs = server.requests(); + assert_eq!( + reqs[0].path, + "/repos/o/r/actions/workflows/ci.yml/dispatches" + ); + let sent: serde_json::Value = serde_json::from_str(&reqs[0].body).unwrap(); + assert_eq!(sent["ref"], "main"); + } } diff --git a/src/github/client.rs b/src/github/client.rs index 8eab2112..460d0b8c 100644 --- a/src/github/client.rs +++ b/src/github/client.rs @@ -57,6 +57,18 @@ impl Client { } } + /// Construct a client aimed at an arbitrary base URL (e.g. a local mock + /// server) with an explicit token. Test-only: production code goes through + /// [`Client::new`] / [`Client::anonymous`], which always target GitHub. + #[cfg(test)] + pub(crate) fn for_test(base_url: String, token: Option) -> Client { + Client { + agent: Self::agent(), + token, + base_url, + } + } + pub fn request( &self, method: &str, @@ -168,4 +180,81 @@ mod tests { assert!(as_array(&serde_json::json!({})).is_empty()); assert!(as_array(&serde_json::Value::Null).is_empty()); } + + #[test] + fn map_status_429_is_rate_limited() { + assert!(matches!( + map_status(429, None, String::new()), + GitHubError::RateLimited(_) + )); + } + + use crate::github::test_support::{MockResponse, MockServer}; + + #[test] + fn request_refuses_writes_without_a_token_before_any_network_call() { + // No server needed: the guard short-circuits before a request is made. + let client = Client::for_test("http://127.0.0.1:1".to_string(), None); + let err = client.request("POST", "/x", None).unwrap_err(); + assert!(matches!(err, GitHubError::NoAuth(_))); + } + + #[test] + fn request_get_parses_json_and_sends_expected_headers() { + let server = MockServer::start(vec![MockResponse::json(200, r#"{"ok":true}"#)]); + let client = server.client(None); + let value = client.request("GET", "/probe", None).unwrap(); + assert_eq!(value["ok"], true); + + let reqs = server.requests(); + assert_eq!(reqs.len(), 1); + assert_eq!(reqs[0].method, "GET"); + assert_eq!(reqs[0].path, "/probe"); + } + + #[test] + fn request_sends_json_body_on_writes() { + let server = MockServer::start(vec![MockResponse::json(201, r#"{"created":1}"#)]); + let client = server.client(Some("tok")); + let body = serde_json::json!({ "name": "value" }); + let value = client.request("POST", "/thing", Some(body)).unwrap(); + assert_eq!(value["created"], 1); + + let reqs = server.requests(); + assert_eq!(reqs[0].method, "POST"); + let sent: serde_json::Value = serde_json::from_str(&reqs[0].body).unwrap(); + assert_eq!(sent["name"], "value"); + } + + #[test] + fn request_empty_body_becomes_json_null() { + let server = MockServer::start(vec![MockResponse::json(204, "")]); + let client = server.client(Some("tok")); + let value = client + .request("POST", "/empty", Some(serde_json::json!({}))) + .unwrap(); + assert_eq!(value, serde_json::Value::Null); + let _ = server.requests(); + } + + #[test] + fn request_maps_error_status_from_the_wire() { + let server = MockServer::start(vec![ + MockResponse::json(403, r#"{"message":"limited"}"#) + .with_header("x-ratelimit-remaining", "0"), + ]); + let client = server.client(Some("tok")); + let err = client.request("GET", "/limited", None).unwrap_err(); + assert!(matches!(err, GitHubError::RateLimited(_))); + let _ = server.requests(); + } + + #[test] + fn request_reports_parse_error_on_malformed_json() { + let server = MockServer::start(vec![MockResponse::json(200, "not json")]); + let client = server.client(None); + let err = client.request("GET", "/bad", None).unwrap_err(); + assert!(matches!(err, GitHubError::Parse(_))); + let _ = server.requests(); + } } diff --git a/src/github/identity.rs b/src/github/identity.rs index 71ab23fd..ed2c54b1 100644 --- a/src/github/identity.rs +++ b/src/github/identity.rs @@ -79,4 +79,32 @@ mod tests { fn repo_id_parse_is_none_for_single_segment() { assert!(RepoId::parse("not-a-url").is_none()); } + + #[test] + fn normalize_repo_handles_ports_and_extra_path_segments() { + // ssh:// with an explicit port, and a deeper path than owner/repo. + assert_eq!( + normalize_repo("ssh://git@github.com:22/getappz/agentflare.git"), + "getappz/agentflare" + ); + assert_eq!( + normalize_repo("https://gitlab.com/group/subgroup/proj.git"), + "subgroup/proj" + ); + } + + #[test] + fn normalize_repo_trims_trailing_slash_and_git_suffix() { + assert_eq!(normalize_repo("https://github.com/o/r.git/"), "o/r"); + } + + #[test] + fn normalize_repo_passes_through_a_bare_single_segment() { + assert_eq!(normalize_repo("justname"), "justname"); + } + + #[test] + fn repo_id_parse_is_none_for_empty_input() { + assert!(RepoId::parse("").is_none()); + } } diff --git a/src/github/issues.rs b/src/github/issues.rs index e263decf..ff4f3fc8 100644 --- a/src/github/issues.rs +++ b/src/github/issues.rs @@ -112,4 +112,92 @@ mod tests { assert!(minimal.get("labels").is_none()); assert!(minimal.get("assignees").is_none()); } + + use crate::github::test_support::{MockResponse, MockServer}; + + fn repo() -> RepoId { + RepoId { + owner: "o".into(), + repo: "r".into(), + } + } + + #[test] + fn create_posts_to_issues() { + let server = MockServer::start(vec![MockResponse::json( + 201, + r#"{"number":11,"html_url":"u","state":"open","title":"t"}"#, + )]); + let client = server.client(Some("tok")); + let issue = create(&client, &repo(), "t", None, &["bug".into()], &[]).unwrap(); + assert_eq!(issue.number, 11); + let reqs = server.requests(); + assert_eq!(reqs[0].path, "/repos/o/r/issues"); + let sent: serde_json::Value = serde_json::from_str(&reqs[0].body).unwrap(); + assert_eq!(sent["labels"][0], "bug"); + } + + #[test] + fn list_encodes_state() { + let server = MockServer::start(vec![MockResponse::json( + 200, + r#"[{"number":1,"html_url":"u","state":"closed","title":"a"}]"#, + )]); + let client = server.client(None); + let issues = list(&client, &repo(), "closed").unwrap(); + assert_eq!(issues.len(), 1); + assert_eq!( + server.requests()[0].path, + "/repos/o/r/issues?state=closed&per_page=100&page=1" + ); + } + + #[test] + fn get_fetches_single_issue() { + let server = MockServer::start(vec![MockResponse::json( + 200, + r#"{"number":3,"html_url":"u","state":"open","title":"x"}"#, + )]); + let client = server.client(None); + let issue = get(&client, &repo(), 3).unwrap(); + assert_eq!(issue.number, 3); + assert_eq!(server.requests()[0].path, "/repos/o/r/issues/3"); + } + + #[test] + fn comment_posts_body() { + let server = MockServer::start(vec![MockResponse::json(201, r#"{"id":1}"#)]); + let client = server.client(Some("tok")); + comment(&client, &repo(), 4, "hi").unwrap(); + let reqs = server.requests(); + assert_eq!(reqs[0].path, "/repos/o/r/issues/4/comments"); + let sent: serde_json::Value = serde_json::from_str(&reqs[0].body).unwrap(); + assert_eq!(sent["body"], "hi"); + } + + #[test] + fn close_patches_state_to_closed() { + let server = MockServer::start(vec![MockResponse::json( + 200, + r#"{"number":6,"html_url":"u","state":"closed","title":"x"}"#, + )]); + let client = server.client(Some("tok")); + let issue = close(&client, &repo(), 6).unwrap(); + assert_eq!(issue.state, "closed"); + let reqs = server.requests(); + assert_eq!(reqs[0].method, "PATCH"); + let sent: serde_json::Value = serde_json::from_str(&reqs[0].body).unwrap(); + assert_eq!(sent["state"], "closed"); + } + + #[test] + fn add_labels_posts_the_label_list() { + let server = MockServer::start(vec![MockResponse::json(200, "[]")]); + let client = server.client(Some("tok")); + add_labels(&client, &repo(), 2, &["a".into(), "b".into()]).unwrap(); + let reqs = server.requests(); + assert_eq!(reqs[0].path, "/repos/o/r/issues/2/labels"); + let sent: serde_json::Value = serde_json::from_str(&reqs[0].body).unwrap(); + assert_eq!(sent["labels"][1], "b"); + } } diff --git a/src/github/mod.rs b/src/github/mod.rs index ec4c5ce3..3e79ec92 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -14,6 +14,9 @@ pub mod models; pub mod pulls; pub mod releases; +#[cfg(test)] +pub(crate) mod test_support; + pub use client::Client; pub use identity::RepoId; @@ -76,4 +79,43 @@ mod encode_tests { assert_eq!(encode_query("open"), "open"); assert_eq!(encode_query("a b"), "a%20b"); } + + #[test] + fn encode_query_encodes_multibyte_utf8() { + // Each byte of a non-ASCII char is percent-encoded individually. + assert_eq!(encode_query("café"), "caf%C3%A9"); + } + + #[test] + fn github_error_display_matches_each_variant() { + assert_eq!( + GitHubError::NoAuth("no creds".into()).to_string(), + "no creds" + ); + assert_eq!( + GitHubError::Forbidden("forbidden".into()).to_string(), + "forbidden" + ); + assert_eq!( + GitHubError::RateLimited("slow down".into()).to_string(), + "slow down" + ); + assert_eq!(GitHubError::NotFound.to_string(), "not found"); + assert_eq!( + GitHubError::Http { + status: 500, + body: "boom".into() + } + .to_string(), + "GitHub HTTP 500: boom" + ); + assert_eq!( + GitHubError::Transport("reset".into()).to_string(), + "transport error: reset" + ); + assert_eq!( + GitHubError::Parse("bad json".into()).to_string(), + "response parse error: bad json" + ); + } } diff --git a/src/github/pulls.rs b/src/github/pulls.rs index db07b4f8..28eda1b0 100644 --- a/src/github/pulls.rs +++ b/src/github/pulls.rs @@ -80,6 +80,8 @@ pub fn request_review( mod tests { use super::*; + use crate::github::test_support::{MockResponse, MockServer}; + #[test] fn create_body_includes_optional_body_only_when_present() { let with = create_body("t", "h", "b", Some("desc")); @@ -88,4 +90,92 @@ mod tests { let without = create_body("t", "h", "b", None); assert!(without.get("body").is_none()); } + + fn repo() -> RepoId { + RepoId { + owner: "o".into(), + repo: "r".into(), + } + } + + #[test] + fn create_posts_to_pulls_and_parses_the_response() { + let server = MockServer::start(vec![MockResponse::json( + 201, + r#"{"number":7,"html_url":"https://gh/o/r/pull/7","state":"open","title":"t"}"#, + )]); + let client = server.client(Some("tok")); + let pr = create(&client, &repo(), "t", "head", "main", Some("desc")).unwrap(); + assert_eq!(pr.number, 7); + + let reqs = server.requests(); + assert_eq!(reqs[0].method, "POST"); + assert_eq!(reqs[0].path, "/repos/o/r/pulls"); + let sent: serde_json::Value = serde_json::from_str(&reqs[0].body).unwrap(); + assert_eq!(sent["head"], "head"); + assert_eq!(sent["base"], "main"); + assert_eq!(sent["body"], "desc"); + } + + #[test] + fn list_encodes_state_in_the_query() { + let server = MockServer::start(vec![MockResponse::json( + 200, + r#"[{"number":1,"html_url":"u","state":"open","title":"a"}]"#, + )]); + let client = server.client(None); + let prs = list(&client, &repo(), "open").unwrap(); + assert_eq!(prs.len(), 1); + assert_eq!( + server.requests()[0].path, + "/repos/o/r/pulls?state=open&per_page=100&page=1" + ); + } + + #[test] + fn get_fetches_a_single_pull() { + let server = MockServer::start(vec![MockResponse::json( + 200, + r#"{"number":9,"html_url":"u","state":"closed","title":"x"}"#, + )]); + let client = server.client(None); + let pr = get(&client, &repo(), 9).unwrap(); + assert_eq!(pr.state, "closed"); + assert_eq!(server.requests()[0].path, "/repos/o/r/pulls/9"); + } + + #[test] + fn merge_puts_the_chosen_method() { + let server = MockServer::start(vec![MockResponse::json(200, r#"{"merged":true}"#)]); + let client = server.client(Some("tok")); + merge(&client, &repo(), 3, "squash").unwrap(); + let reqs = server.requests(); + assert_eq!(reqs[0].method, "PUT"); + assert_eq!(reqs[0].path, "/repos/o/r/pulls/3/merge"); + let sent: serde_json::Value = serde_json::from_str(&reqs[0].body).unwrap(); + assert_eq!(sent["merge_method"], "squash"); + } + + #[test] + fn comment_posts_to_the_issues_comments_endpoint() { + let server = MockServer::start(vec![MockResponse::json(201, r#"{"id":1}"#)]); + let client = server.client(Some("tok")); + comment(&client, &repo(), 5, "hello").unwrap(); + let reqs = server.requests(); + assert_eq!(reqs[0].path, "/repos/o/r/issues/5/comments"); + let sent: serde_json::Value = serde_json::from_str(&reqs[0].body).unwrap(); + assert_eq!(sent["body"], "hello"); + } + + #[test] + fn request_review_sends_the_reviewer_list() { + let server = MockServer::start(vec![MockResponse::json(201, r#"{"id":1}"#)]); + let client = server.client(Some("tok")); + request_review(&client, &repo(), 8, &["alice".into(), "bob".into()]).unwrap(); + let reqs = server.requests(); + assert_eq!(reqs[0].path, "/repos/o/r/pulls/8/requested_reviewers"); + let sent: serde_json::Value = serde_json::from_str(&reqs[0].body).unwrap(); + assert_eq!(sent["reviewers"][0], "alice"); + assert_eq!(sent["reviewers"][1], "bob"); + } } diff --git a/src/github/releases.rs b/src/github/releases.rs index e034293e..64fe3a1f 100644 --- a/src/github/releases.rs +++ b/src/github/releases.rs @@ -73,4 +73,57 @@ mod tests { assert!(bare.get("name").is_none()); assert!(bare.get("body").is_none()); } + + use crate::github::test_support::{MockResponse, MockServer}; + + fn repo() -> RepoId { + RepoId { + owner: "o".into(), + repo: "r".into(), + } + } + + const REL: &str = r#"{"id":1,"tag_name":"v1.0.0","html_url":"u"}"#; + + #[test] + fn list_gets_releases() { + let server = MockServer::start(vec![MockResponse::json(200, "[]")]); + let client = server.client(None); + assert!(list(&client, &repo()).unwrap().is_empty()); + assert_eq!( + server.requests()[0].path, + "/repos/o/r/releases?per_page=100&page=1" + ); + } + + #[test] + fn get_fetches_by_id() { + let server = MockServer::start(vec![MockResponse::json(200, REL)]); + let client = server.client(None); + let rel = get(&client, &repo(), 1).unwrap(); + assert_eq!(rel.tag_name, "v1.0.0"); + assert_eq!(server.requests()[0].path, "/repos/o/r/releases/1"); + } + + #[test] + fn latest_hits_the_latest_endpoint() { + let server = MockServer::start(vec![MockResponse::json(200, REL)]); + let client = server.client(None); + latest(&client, &repo()).unwrap(); + assert_eq!(server.requests()[0].path, "/repos/o/r/releases/latest"); + } + + #[test] + fn create_posts_release_body() { + let server = MockServer::start(vec![MockResponse::json(201, REL)]); + let client = server.client(Some("tok")); + create(&client, &repo(), "v1.0.0", Some("First"), None, true, false).unwrap(); + let reqs = server.requests(); + assert_eq!(reqs[0].method, "POST"); + assert_eq!(reqs[0].path, "/repos/o/r/releases"); + let sent: serde_json::Value = serde_json::from_str(&reqs[0].body).unwrap(); + assert_eq!(sent["tag_name"], "v1.0.0"); + assert_eq!(sent["draft"], true); + assert_eq!(sent["name"], "First"); + } } diff --git a/src/github/test_support.rs b/src/github/test_support.rs new file mode 100644 index 00000000..4d3c0af6 --- /dev/null +++ b/src/github/test_support.rs @@ -0,0 +1,157 @@ +//! Test-only HTTP mock for exercising the GitHub client and resource +//! functions without touching the network. A `MockServer` binds to an +//! ephemeral localhost port, serves a fixed queue of canned responses, and +//! records the requests it received so tests can assert on method, path, and +//! body. + +use super::Client; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::thread::JoinHandle; + +/// One canned response the mock server will hand out, in order. +pub struct MockResponse { + pub status: u16, + pub body: String, + pub headers: Vec<(String, String)>, +} + +impl MockResponse { + pub fn json(status: u16, body: &str) -> Self { + MockResponse { + status, + body: body.to_string(), + headers: Vec::new(), + } + } + + pub fn with_header(mut self, name: &str, value: &str) -> Self { + self.headers.push((name.to_string(), value.to_string())); + self + } +} + +/// A request the mock server observed. +#[derive(Debug, Clone)] +pub struct RecordedRequest { + pub method: String, + pub path: String, + pub body: String, +} + +pub struct MockServer { + pub base_url: String, + handle: Option>>, +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 201 => "Created", + 204 => "No Content", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 429 => "Too Many Requests", + _ => "Status", + } +} + +impl MockServer { + /// Start a server that will serve exactly `responses.len()` requests, one + /// per queued response, then stop. + pub fn start(responses: Vec) -> MockServer { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server"); + let port = listener.local_addr().unwrap().port(); + let base_url = format!("http://127.0.0.1:{port}"); + + let handle = std::thread::spawn(move || { + let mut recorded = Vec::new(); + for response in responses { + let (stream, _) = match listener.accept() { + Ok(pair) => pair, + Err(_) => break, + }; + let mut stream = stream; + let recorded_req = handle_connection(&mut stream); + write_response(&mut stream, &response); + if let Some(req) = recorded_req { + recorded.push(req); + } + } + recorded + }); + + MockServer { + base_url, + handle: Some(handle), + } + } + + /// A client pointed at this mock server. `token` controls whether the + /// client is authenticated (writes require a token). + pub fn client(&self, token: Option<&str>) -> Client { + Client::for_test(self.base_url.clone(), token.map(str::to_string)) + } + + /// Stop the server and return every request it saw, in order. + pub fn requests(mut self) -> Vec { + self.handle + .take() + .expect("server already joined") + .join() + .expect("mock server thread panicked") + } +} + +fn handle_connection(stream: &mut std::net::TcpStream) -> Option { + let mut reader = BufReader::new(stream.try_clone().ok()?); + + let mut request_line = String::new(); + reader.read_line(&mut request_line).ok()?; + let mut parts = request_line.split_whitespace(); + let method = parts.next()?.to_string(); + let path = parts.next()?.to_string(); + + let mut content_length = 0usize; + loop { + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + if line == "\r\n" || line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + content_length = value.trim().parse().unwrap_or(0); + } + } + + let mut body = vec![0u8; content_length]; + if content_length > 0 { + reader.read_exact(&mut body).ok()?; + } + + Some(RecordedRequest { + method, + path, + body: String::from_utf8_lossy(&body).into_owned(), + }) +} + +fn write_response(stream: &mut std::net::TcpStream, response: &MockResponse) { + let mut out = format!( + "HTTP/1.1 {} {}\r\n", + response.status, + reason(response.status) + ); + out.push_str("Content-Type: application/json\r\n"); + out.push_str(&format!("Content-Length: {}\r\n", response.body.len())); + for (name, value) in &response.headers { + out.push_str(&format!("{name}: {value}\r\n")); + } + out.push_str("Connection: close\r\n\r\n"); + out.push_str(&response.body); + let _ = stream.write_all(out.as_bytes()); + let _ = stream.flush(); +}