use std::path::Path;
use httpmock::prelude::*;
use cursus::forge::CodeForgeClient;
use cursus::forge::github::OctocrabGitHubClient;
use cursus::forge::github::remote::GitHubRepo;
fn mock_client(server: &MockServer) -> OctocrabGitHubClient {
let client = octocrab::Octocrab::builder()
.personal_token("test-token".to_string())
.base_uri(server.base_url())
.unwrap()
.build()
.unwrap();
OctocrabGitHubClient::new(client, repo())
}
fn repo() -> GitHubRepo {
GitHubRepo::new("owner", "repo").unwrap()
}
const RELEASE_JSON: &str = r#"{
"url": "https://api.github.com/repos/owner/repo/releases/42",
"html_url": "https://github.com/owner/repo/releases/tag/v1.0.0",
"assets_url": "https://api.github.com/repos/owner/repo/releases/42/assets",
"upload_url": "https://uploads.github.com/repos/owner/repo/releases/42/assets{?name,label}",
"id": 42,
"node_id": "abc",
"tag_name": "v1.0.0",
"target_commitish": "main",
"draft": true,
"prerelease": false,
"created_at": "2026-01-01T00:00:00Z",
"assets": []
}"#;
const PR_JSON: &str = r#"{
"url": "https://api.github.com/repos/owner/repo/pulls/7",
"id": 1,
"node_id": "PR_7",
"number": 7,
"html_url": "https://github.com/owner/repo/pull/7",
"diff_url": "https://github.com/owner/repo/pull/7.diff",
"patch_url": "https://github.com/owner/repo/pull/7.patch",
"issue_url": "https://api.github.com/repos/owner/repo/issues/7",
"commits_url": "https://api.github.com/repos/owner/repo/pulls/7/commits",
"review_comments_url": "https://api.github.com/repos/owner/repo/pulls/7/comments",
"review_comment_url": "https://api.github.com/repos/owner/repo/pulls/comments",
"comments_url": "https://api.github.com/repos/owner/repo/issues/7/comments",
"statuses_url": "https://api.github.com/repos/owner/repo/statuses/abc123",
"state": "open",
"title": "Release",
"user": {
"login": "octocat",
"id": 1,
"node_id": "U_1",
"avatar_url": "https://github.com/images/avatar.png",
"gravatar_id": "",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following",
"gists_url": "https://api.github.com/users/octocat/gists",
"starred_url": "https://api.github.com/users/octocat/starred",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events",
"received_events_url": "https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false
},
"labels": [],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"merged": false,
"assignees": [],
"requested_reviewers": [],
"requested_teams": [],
"head": {"ref": "feature", "sha": "abc123"},
"base": {"ref": "main", "sha": "def456"},
"_links": {},
"author_association": "OWNER",
"additions": 0,
"deletions": 0,
"changed_files": 0,
"commits": 0,
"review_comments": 0,
"comments": 0
}"#;
#[tokio::test]
async fn create_release_returns_id() {
let server = MockServer::start();
let _mock = server.mock(|when, then| {
when.method(POST).path("/repos/owner/repo/releases");
then.status(201)
.header("Content-Type", "application/json")
.body(RELEASE_JSON);
});
let client = mock_client(&server);
let id = client
.create_release("v1.0.0", "Release 1.0.0", "Body")
.await
.unwrap();
assert_eq!(id, "42");
}
#[tokio::test]
async fn create_release_propagates_api_error() {
let server = MockServer::start();
let _mock = server.mock(|when, then| {
when.method(POST).path("/repos/owner/repo/releases");
then.status(422)
.header("Content-Type", "application/json")
.body(r#"{"message": "Validation Failed"}"#);
});
let client = mock_client(&server);
let result = client.create_release("v1.0.0", "Release", "Body").await;
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("Failed to create GitHub Release"),
"Error should contain context, got: {msg}"
);
}
#[tokio::test]
async fn upload_asset_succeeds_with_valid_file() {
let server = MockServer::start();
let base = server.base_url();
let release_json = format!(
r#"{{
"url": "{base}/repos/owner/repo/releases/42",
"html_url": "https://github.com/owner/repo/releases/tag/v1.0.0",
"assets_url": "{base}/repos/owner/repo/releases/42/assets",
"upload_url": "{base}/repos/owner/repo/releases/42/assets{{?name,label}}",
"id": 42, "node_id": "abc", "tag_name": "v1.0.0", "target_commitish": "main",
"draft": true, "prerelease": false, "created_at": "2026-01-01T00:00:00Z", "assets": []
}}"#
);
let _mock_get = server.mock(|when, then| {
when.method(GET).path("/repos/owner/repo/releases/42");
then.status(200)
.header("Content-Type", "application/json")
.body(&release_json);
});
let _mock_upload = server.mock(|when, then| {
when.method(POST)
.path("/repos/owner/repo/releases/42/assets");
then.status(201)
.header("Content-Type", "application/json")
.body(r#"{"id": 1, "node_id": "abc", "name": "file.tar.gz", "url": "https://example.com", "browser_download_url": "https://example.com/file.tar.gz", "state": "uploaded", "size": 4, "download_count": 0, "content_type": "application/octet-stream", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"}"#);
});
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), b"data").unwrap();
let client = mock_client(&server);
let result = client.upload_asset("42", "file.tar.gz", tmp.path()).await;
assert!(result.is_ok(), "upload_asset failed: {result:?}");
}
#[tokio::test]
async fn upload_asset_rejects_non_numeric_release_id() {
let server = MockServer::start();
let client = mock_client(&server);
let result = client
.upload_asset("abc", "file.tar.gz", Path::new("/tmp/file"))
.await;
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("Invalid GitHub release_id"),
"Error should mention invalid ID, got: {msg}"
);
}
#[tokio::test]
async fn upload_asset_errors_on_missing_file() {
let server = MockServer::start();
let client = mock_client(&server);
let result = client
.upload_asset(
"42",
"missing.tar.gz",
Path::new("/nonexistent/path/missing.tar.gz"),
)
.await;
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("missing.tar.gz"),
"Error should mention file, got: {msg}"
);
}
#[tokio::test]
async fn create_pull_request_returns_url() {
let server = MockServer::start();
let _mock = server.mock(|when, then| {
when.method(POST).path("/repos/owner/repo/pulls");
then.status(201)
.header("Content-Type", "application/json")
.body(PR_JSON);
});
let client = mock_client(&server);
let url = client
.create_pull_request("Title", "Body", "feature", "main")
.await
.unwrap();
assert!(url.contains("pull/7"), "Expected PR URL, got: {url}");
}
#[tokio::test]
async fn create_pull_request_propagates_api_error() {
let server = MockServer::start();
let _mock = server.mock(|when, then| {
when.method(POST).path("/repos/owner/repo/pulls");
then.status(422)
.header("Content-Type", "application/json")
.body(r#"{"message": "Validation Failed"}"#);
});
let client = mock_client(&server);
let result = client
.create_pull_request("Title", "Body", "feature", "main")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn find_open_pull_request_returns_pr_when_found() {
let server = MockServer::start();
let _mock = server.mock(|when, then| {
when.method(GET).path("/repos/owner/repo/pulls");
then.status(200)
.header("Content-Type", "application/json")
.body(format!("[{PR_JSON}]"));
});
let client = mock_client(&server);
let pr = client.find_open_pull_request("feature").await.unwrap();
assert!(pr.is_some());
assert_eq!(pr.unwrap().number, 7);
}
#[tokio::test]
async fn find_open_pull_request_returns_none_when_empty() {
let server = MockServer::start();
let _mock = server.mock(|when, then| {
when.method(GET).path("/repos/owner/repo/pulls");
then.status(200)
.header("Content-Type", "application/json")
.body("[]");
});
let client = mock_client(&server);
let pr = client.find_open_pull_request("feature").await.unwrap();
assert!(pr.is_none());
}
#[tokio::test]
async fn update_pull_request_returns_url() {
let server = MockServer::start();
let _mock = server.mock(|when, then| {
when.method(PATCH).path("/repos/owner/repo/pulls/7");
then.status(200)
.header("Content-Type", "application/json")
.body(PR_JSON);
});
let client = mock_client(&server);
let url = client
.update_pull_request(7, "Updated", "New body")
.await
.unwrap();
assert!(url.contains("pull/7"), "Expected PR URL, got: {url}");
}
#[tokio::test]
async fn update_pull_request_propagates_api_error() {
let server = MockServer::start();
let _mock = server.mock(|when, then| {
when.method(PATCH).path("/repos/owner/repo/pulls/7");
then.status(404)
.header("Content-Type", "application/json")
.body(r#"{"message": "Not Found"}"#);
});
let client = mock_client(&server);
let result = client.update_pull_request(7, "Title", "Body").await;
assert!(result.is_err());
}
#[tokio::test]
async fn publish_release_succeeds() {
let server = MockServer::start();
let _mock = server.mock(|when, then| {
when.method(PATCH).path("/repos/owner/repo/releases/42");
then.status(200)
.header("Content-Type", "application/json")
.body(RELEASE_JSON);
});
let client = mock_client(&server);
let result = client.publish_release("42").await;
assert!(result.is_ok(), "publish_release failed: {result:?}");
}
#[tokio::test]
async fn publish_release_rejects_non_numeric_release_id() {
let server = MockServer::start();
let client = mock_client(&server);
let result = client.publish_release("abc").await;
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("Invalid GitHub release_id"),
"Error should mention invalid ID, got: {msg}"
);
}
#[tokio::test]
async fn publish_release_propagates_api_error() {
let server = MockServer::start();
let _mock = server.mock(|when, then| {
when.method(PATCH).path("/repos/owner/repo/releases/42");
then.status(404)
.header("Content-Type", "application/json")
.body(r#"{"message": "Not Found"}"#);
});
let client = mock_client(&server);
let result = client.publish_release("42").await;
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("Failed to publish GitHub Release"),
"Error should contain context, got: {msg}"
);
}