From c27cbbcbdb40375e59fe5791cce5c9986ffdc62e Mon Sep 17 00:00:00 2001 From: AppzDev Date: Fri, 17 Jul 2026 19:02:06 +0000 Subject: [PATCH] test(github): cover client request path and REST resource functions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/github/actions.rs | 68 ++++++++++++++++ src/github/client.rs | 89 +++++++++++++++++++++ src/github/identity.rs | 28 +++++++ src/github/issues.rs | 85 ++++++++++++++++++++ src/github/mod.rs | 42 ++++++++++ src/github/pulls.rs | 87 ++++++++++++++++++++ src/github/releases.rs | 50 ++++++++++++ src/github/test_support.rs | 157 +++++++++++++++++++++++++++++++++++++ 8 files changed, 606 insertions(+) create mode 100644 src/github/test_support.rs diff --git a/src/github/actions.rs b/src/github/actions.rs index bc3b4822..10904238 100644 --- a/src/github/actions.rs +++ b/src/github/actions.rs @@ -87,4 +87,72 @@ 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"); + } + + #[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" + ); + } + + #[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 3cd7930a..5a87c3b8 100644 --- a/src/github/client.rs +++ b/src/github/client.rs @@ -54,6 +54,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, @@ -126,4 +138,81 @@ mod tests { GitHubError::Http { status: 500, .. } )); } + + #[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 9b2c13e6..800c254f 100644 --- a/src/github/issues.rs +++ b/src/github/issues.rs @@ -112,4 +112,89 @@ 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"); + } + + #[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 ef0f220e..33eecdfc 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,89 @@ 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"); + } + + #[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 45dbeb0f..a73c217c 100644 --- a/src/github/releases.rs +++ b/src/github/releases.rs @@ -73,4 +73,54 @@ 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"); + } + + #[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(); +}