use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use travelagent_core::error::TrvError;
use travelagent_core::forge::*;
use travelagent_forge_github::GitHubForge;
fn test_pr_id() -> PrId {
PrId {
owner: "octocat".into(),
repo: "hello-world".into(),
number: 42,
}
}
async fn setup() -> (MockServer, GitHubForge) {
let server = MockServer::start().await;
let forge = GitHubForge::with_token_insecure(&server.uri(), "test-token".into()).unwrap();
(server, forge)
}
#[tokio::test]
async fn get_pr_returns_metadata() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls/42"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"number": 42,
"title": "Amazing feature",
"body": "This is the PR body",
"state": "open",
"draft": false,
"merged_at": null,
"user": { "login": "alice", "id": 1 },
"base": { "ref": "main", "sha": "base123" },
"head": { "ref": "feat-branch", "sha": "head456" },
"created_at": "2024-06-15T10:30:00Z",
"mergeable_state": "clean",
"mergeable": true
})))
.mount(&server)
.await;
let pr = forge.get_pr(&test_pr_id()).await.unwrap();
assert_eq!(pr.title, "Amazing feature");
assert_eq!(pr.body, "This is the PR body");
assert_eq!(pr.author, "alice");
assert_eq!(pr.state, PrState::Open);
assert_eq!(pr.base_branch, "main");
assert_eq!(pr.head_branch, "feat-branch");
assert_eq!(pr.mergeable, Some(MergeableStatus::Clean));
assert!(!pr.is_draft);
}
#[tokio::test]
async fn get_pr_merged_state() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"number": 42,
"title": "Merged PR",
"body": null,
"state": "closed",
"draft": null,
"merged_at": "2024-06-16T12:00:00Z",
"user": { "login": "bob", "id": 2 },
"base": { "ref": "main", "sha": "aaa" },
"head": { "ref": "fix", "sha": "bbb" },
"created_at": "2024-06-15T10:30:00Z",
"mergeable_state": null,
"mergeable": null
})))
.mount(&server)
.await;
let pr = forge.get_pr(&test_pr_id()).await.unwrap();
assert_eq!(pr.state, PrState::Merged);
assert_eq!(pr.body, "");
}
#[tokio::test]
async fn get_pr_commits_with_pagination() {
let (server, forge) = setup().await;
let page2_url = format!(
"{}/repos/octocat/hello-world/pulls/42/commits?per_page=100&page=2",
server.uri()
);
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls/42/commits"))
.and(wiremock::matchers::query_param("page", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"sha": "commit2sha000000000000000000000000000000",
"commit": {
"message": "Second commit\n\nWith a body",
"author": { "name": "Bob", "date": "2024-06-15T11:00:00Z" }
},
"author": { "login": "bob", "id": 2 }
}
])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls/42/commits"))
.respond_with(
ResponseTemplate::new(200)
.append_header("link", &format!("<{page2_url}>; rel=\"next\""))
.set_body_json(serde_json::json!([
{
"sha": "commit1sha000000000000000000000000000000",
"commit": {
"message": "First commit",
"author": { "name": "Alice", "date": "2024-06-15T10:00:00Z" }
},
"author": { "login": "alice", "id": 1 }
}
])),
)
.up_to_n_times(1)
.mount(&server)
.await;
let commits = forge.get_pr_commits(&test_pr_id()).await.unwrap();
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].summary, "First commit");
assert_eq!(commits[0].author, "alice");
assert_eq!(commits[0].short_id, "commit1");
assert_eq!(commits[1].summary, "Second commit");
assert_eq!(commits[1].body, Some("With a body".into()));
assert_eq!(commits[1].author, "bob");
}
#[tokio::test]
async fn get_comments_returns_review_comments() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls/42/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"id": 100,
"body": "Looks good here",
"path": "src/main.rs",
"line": 10,
"start_line": null,
"side": "RIGHT",
"start_side": null,
"commit_id": "abc123",
"user": { "login": "reviewer", "id": 5 },
"created_at": "2024-06-15T12:00:00Z",
"in_reply_to_id": null
},
{
"id": 101,
"body": "Reply to above",
"path": "src/main.rs",
"line": 10,
"start_line": null,
"side": "RIGHT",
"start_side": null,
"commit_id": "abc123",
"user": { "login": "author", "id": 1 },
"created_at": "2024-06-15T13:00:00Z",
"in_reply_to_id": 100
}
])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/issues/42/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&server)
.await;
let comments = forge.get_comments(&test_pr_id()).await.unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].id, 100);
assert_eq!(comments[0].author, "reviewer");
assert_eq!(comments[0].body, "Looks good here");
assert_eq!(comments[0].path, Some("src/main.rs".into()));
assert_eq!(comments[0].line, Some(10));
assert_eq!(comments[0].in_reply_to, None);
assert_eq!(comments[1].id, 101);
assert_eq!(comments[1].in_reply_to, Some(100));
}
#[tokio::test]
async fn current_user_returns_user() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"login": "octocat",
"id": 42
})))
.mount(&server)
.await;
let user = forge.current_user().await.unwrap();
assert_eq!(user.login, "octocat");
assert_eq!(user.id, 42);
}
#[tokio::test]
async fn get_pr_returns_not_found_on_404() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls/42"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let err = forge.get_pr(&test_pr_id()).await.unwrap_err();
assert!(matches!(err, TrvError::NotFound(_)));
}
#[tokio::test]
async fn get_pr_returns_auth_error_on_401() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls/42"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let err = forge.get_pr(&test_pr_id()).await.unwrap_err();
assert!(matches!(err, TrvError::AuthError(_)));
}
#[tokio::test]
async fn get_pr_returns_rate_limited_on_403_with_header() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls/42"))
.respond_with(ResponseTemplate::new(403).append_header("x-ratelimit-remaining", "0"))
.mount(&server)
.await;
let err = forge.get_pr(&test_pr_id()).await.unwrap_err();
assert!(matches!(err, TrvError::RateLimited));
}
#[tokio::test]
async fn check_permissions_parses_repo_response() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"permissions": {
"push": true,
"maintain": false,
"admin": false
},
"allow_merge_commit": true,
"allow_squash_merge": true,
"allow_rebase_merge": false
})))
.mount(&server)
.await;
let perms = forge.check_permissions(&test_pr_id()).await.unwrap();
assert!(perms.can_push);
assert!(!perms.can_merge);
assert_eq!(perms.allowed_merge_methods.len(), 2);
assert!(perms.allowed_merge_methods.contains(&MergeMethod::Merge));
assert!(perms.allowed_merge_methods.contains(&MergeMethod::Squash));
assert!(!perms.allowed_merge_methods.contains(&MergeMethod::Rebase));
}
#[tokio::test]
async fn get_pr_files_parses_patches() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls/42/files"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"filename": "src/lib.rs",
"status": "modified",
"additions": 2,
"deletions": 1,
"patch": "@@ -1,3 +1,4 @@\n fn main() {\n- old();\n+ new();\n+ extra();\n }",
"previous_filename": null
},
{
"filename": "new_file.rs",
"status": "added",
"additions": 3,
"deletions": 0,
"patch": "@@ -0,0 +1,3 @@\n+line1\n+line2\n+line3",
"previous_filename": null
}
])))
.mount(&server)
.await;
let files = forge.get_pr_files(&test_pr_id()).await.unwrap();
assert_eq!(files.len(), 2);
assert_eq!(
files[0].new_path,
Some(std::path::PathBuf::from("src/lib.rs"))
);
assert_eq!(
files[0].status,
travelagent_core::model::FileStatus::Modified
);
assert_eq!(files[0].hunks.len(), 1);
assert_eq!(files[0].hunks[0].lines.len(), 5);
assert_eq!(
files[1].new_path,
Some(std::path::PathBuf::from("new_file.rs"))
);
assert_eq!(files[1].status, travelagent_core::model::FileStatus::Added);
assert!(files[1].old_path.is_none());
assert_eq!(files[1].hunks[0].lines.len(), 3);
}
#[tokio::test]
async fn forge_type_is_github() {
let (_, forge) = setup().await;
assert_eq!(forge.forge_type(), ForgeType::GitHub);
}
#[tokio::test]
async fn list_prs_returns_open_rows_with_review_request_flag() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"login": "bob",
"id": 2
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls"))
.and(wiremock::matchers::query_param("state", "open"))
.and(wiremock::matchers::query_param("sort", "updated"))
.and(wiremock::matchers::query_param("direction", "desc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"number": 101,
"title": "Add thing",
"state": "open",
"draft": false,
"merged_at": null,
"user": { "login": "alice", "id": 1 },
"base": { "ref": "main", "sha": "aaa" },
"head": { "ref": "feat", "sha": "bbb" },
"updated_at": "2024-06-15T10:30:00Z",
"requested_reviewers": [{ "login": "bob", "id": 2 }],
"comments": 1,
"review_comments": 2
},
{
"number": 102,
"title": "Fix nit",
"state": "open",
"draft": true,
"merged_at": null,
"user": { "login": "alice", "id": 1 },
"base": { "ref": "main", "sha": "aaa" },
"head": { "ref": "nit", "sha": "ccc" },
"updated_at": "2024-06-14T08:00:00Z",
"requested_reviewers": [],
"comments": 0,
"review_comments": 0
}
])))
.mount(&server)
.await;
let filter = PrListFilter::default();
let items = forge
.list_prs("octocat", "hello-world", &filter)
.await
.unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].number, 101);
assert_eq!(items[0].comment_count, 3);
assert!(items[0].has_review_requested_from_me);
assert_eq!(items[1].number, 102);
assert!(items[1].is_draft);
assert!(!items[1].has_review_requested_from_me);
}
#[tokio::test]
async fn list_prs_max_caps_items_returned() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"login": "anyone",
"id": 9
})))
.mount(&server)
.await;
let rows: Vec<serde_json::Value> = (1..=10)
.map(|i| {
serde_json::json!({
"number": i,
"title": format!("PR {i}"),
"state": "open",
"draft": false,
"merged_at": null,
"user": { "login": "alice", "id": 1 },
"base": { "ref": "main", "sha": "aaa" },
"head": { "ref": format!("feat-{i}"), "sha": "bbb" },
"updated_at": "2024-06-15T10:30:00Z",
"requested_reviewers": [],
"comments": 0,
"review_comments": 0
})
})
.collect();
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(rows))
.mount(&server)
.await;
let filter = PrListFilter {
max: Some(3),
..PrListFilter::default()
};
let items = forge
.list_prs("octocat", "hello-world", &filter)
.await
.unwrap();
assert_eq!(items.len(), 3);
}
#[tokio::test]
async fn list_prs_client_side_author_filter_is_case_insensitive() {
let (server, forge) = setup().await;
Mock::given(method("GET"))
.and(path("/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"login": "reader",
"id": 1
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/repos/octocat/hello-world/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"number": 1,
"title": "By alice",
"state": "open",
"draft": false,
"merged_at": null,
"user": { "login": "Alice", "id": 1 },
"base": { "ref": "main", "sha": "aaa" },
"head": { "ref": "feat", "sha": "bbb" },
"updated_at": "2024-06-15T10:30:00Z",
"requested_reviewers": [],
"comments": 0,
"review_comments": 0
},
{
"number": 2,
"title": "By bob",
"state": "open",
"draft": false,
"merged_at": null,
"user": { "login": "bob", "id": 2 },
"base": { "ref": "main", "sha": "aaa" },
"head": { "ref": "feat2", "sha": "ccc" },
"updated_at": "2024-06-15T10:30:00Z",
"requested_reviewers": [],
"comments": 0,
"review_comments": 0
}
])))
.mount(&server)
.await;
let filter = PrListFilter {
author: Some("alice".into()),
..PrListFilter::default()
};
let items = forge
.list_prs("octocat", "hello-world", &filter)
.await
.unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].author, "Alice");
}