use prlens::config::GithubConfig;
use prlens::models::ReviewStatus;
use prlens::provider::github::GitHubProvider;
use prlens::provider::Provider;
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn ensure_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
fn author_fixture(login: &str, id: u64) -> serde_json::Value {
json!({
"login": login,
"id": id,
"node_id": "U_testnode",
"avatar_url": "https://avatars.githubusercontent.com/u/1",
"gravatar_id": "",
"url": format!("https://api.github.com/users/{}", login),
"html_url": format!("https://github.com/{}", login),
"followers_url": format!("https://api.github.com/users/{}/followers", login),
"following_url": format!("https://api.github.com/users/{}/following{{/other_user}}", login),
"gists_url": format!("https://api.github.com/users/{}/gists{{/gist_id}}", login),
"starred_url": format!("https://api.github.com/users/{}/starred{{/owner}}{{/repo}}", login),
"subscriptions_url": format!("https://api.github.com/users/{}/subscriptions", login),
"organizations_url": format!("https://api.github.com/users/{}/orgs", login),
"repos_url": format!("https://api.github.com/users/{}/repos", login),
"events_url": format!("https://api.github.com/users/{}/events{{/privacy}}", login),
"received_events_url": format!("https://api.github.com/users/{}/received_events", login),
"type": "User",
"site_admin": false
})
}
fn search_issue_fixture(
number: u64,
title: &str,
author_login: &str,
author_id: u64,
owner: &str,
repo: &str,
) -> serde_json::Value {
json!({
"id": number * 1000,
"node_id": format!("I_test{}", number),
"url": format!("https://api.github.com/repos/{}/{}/issues/{}", owner, repo, number),
"repository_url": format!("https://api.github.com/repos/{}/{}", owner, repo),
"labels_url": format!("https://api.github.com/repos/{}/{}/issues/{}/labels{{/name}}", owner, repo, number),
"comments_url": format!("https://api.github.com/repos/{}/{}/issues/{}/comments", owner, repo, number),
"events_url": format!("https://api.github.com/repos/{}/{}/issues/{}/events", owner, repo, number),
"html_url": format!("https://github.com/{}/{}/pull/{}", owner, repo, number),
"number": number,
"state": "open",
"title": title,
"user": author_fixture(author_login, author_id),
"labels": [],
"assignee": null,
"assignees": [],
"locked": false,
"comments": 0,
"created_at": "2026-05-27T10:00:00Z",
"updated_at": "2026-05-27T10:00:00Z",
"pull_request": {
"url": format!("https://api.github.com/repos/{}/{}/pulls/{}", owner, repo, number),
"html_url": format!("https://github.com/{}/{}/pull/{}", owner, repo, number),
"diff_url": format!("https://github.com/{}/{}/pull/{}.diff", owner, repo, number),
"patch_url": format!("https://github.com/{}/{}/pull/{}.patch", owner, repo, number)
}
})
}
async fn mount_user_endpoint(mock_server: &MockServer, login: &str, id: u64) {
Mock::given(method("GET"))
.and(path("/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(author_fixture(login, id)))
.mount(mock_server)
.await;
}
async fn mount_empty_re_review_search(mock_server: &MockServer) {
Mock::given(method("GET"))
.and(path("/search/issues"))
.and(query_param("q", "is:pr is:open reviewed-by:@me review:approved"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"total_count": 0,
"incomplete_results": false,
"items": []
})))
.mount(mock_server)
.await;
}
#[tokio::test]
async fn list_prs_returns_search_results() {
ensure_crypto_provider();
let mock_server = MockServer::start().await;
let temp_dir = tempfile::TempDir::new().expect("TempDir failed");
let cache_file = temp_dir.path().join("github.json");
mount_user_endpoint(&mock_server, "testuser", 99).await;
Mock::given(method("GET"))
.and(path("/search/issues"))
.and(query_param("q", "is:pr is:open review-requested:@me"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"total_count": 1,
"incomplete_results": false,
"items": [
search_issue_fixture(42, "feat: add feature", "alice", 1, "owner", "repo")
]
})))
.mount(&mock_server)
.await;
mount_empty_re_review_search(&mock_server).await;
let provider = GitHubProvider::new_with_base_url(
GithubConfig::default(),
mock_server.uri(),
cache_file,
);
let result = provider.list_prs().await.unwrap();
assert_eq!(result.len(), 1, "Expected exactly 1 PR");
assert_eq!(result[0].number, 42);
assert_eq!(result[0].title, "feat: add feature");
assert_eq!(result[0].provider, "github");
assert_eq!(result[0].author.login, "alice");
assert_eq!(result[0].repo_full_name, "owner/repo");
}
#[tokio::test]
async fn list_prs_empty_when_no_prs() {
ensure_crypto_provider();
let mock_server = MockServer::start().await;
let temp_dir = tempfile::TempDir::new().expect("TempDir failed");
let cache_file = temp_dir.path().join("github.json");
mount_user_endpoint(&mock_server, "testuser", 99).await;
Mock::given(method("GET"))
.and(path("/search/issues"))
.and(query_param("q", "is:pr is:open review-requested:@me"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"total_count": 0,
"incomplete_results": false,
"items": []
})))
.mount(&mock_server)
.await;
mount_empty_re_review_search(&mock_server).await;
let provider = GitHubProvider::new_with_base_url(
GithubConfig::default(),
mock_server.uri(),
cache_file,
);
let result = provider.list_prs().await;
assert!(result.is_ok(), "Expected Ok result, got: {:?}", result.err());
assert_eq!(result.unwrap().len(), 0, "Expected empty Vec");
}
#[tokio::test]
async fn re_review_detected() {
ensure_crypto_provider();
let mock_server = MockServer::start().await;
let temp_dir = tempfile::TempDir::new().expect("TempDir failed");
let cache_file = temp_dir.path().join("github.json");
mount_user_endpoint(&mock_server, "testuser", 99).await;
Mock::given(method("GET"))
.and(path("/search/issues"))
.and(query_param("q", "is:pr is:open review-requested:@me"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"total_count": 0,
"incomplete_results": false,
"items": []
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/search/issues"))
.and(query_param("q", "is:pr is:open reviewed-by:@me review:approved"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"total_count": 1,
"incomplete_results": false,
"items": [
search_issue_fixture(99, "old feature", "prauthor", 2, "owner", "repo")
]
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/owner/repo/pulls/99/reviews"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{
"id": 1,
"node_id": "PRR_test1",
"html_url": "https://github.com/owner/repo/pull/99#pullrequestreview-1",
"user": author_fixture("testuser", 99),
"body": null,
"commit_id": "abc123",
"state": "APPROVED",
"submitted_at": "2026-05-20T10:00:00Z",
"pull_request_url": "https://api.github.com/repos/owner/repo/pulls/99",
"author_association": "COLLABORATOR",
"_links": {
"html": { "href": "https://github.com/owner/repo/pull/99#pullrequestreview-1" },
"pull_request": { "href": "https://api.github.com/repos/owner/repo/pulls/99" }
}
}
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/owner/repo/pulls/99"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"url": "https://api.github.com/repos/owner/repo/pulls/99",
"id": 99000,
"node_id": "PR_test99",
"html_url": "https://github.com/owner/repo/pull/99",
"diff_url": "https://github.com/owner/repo/pull/99.diff",
"patch_url": "https://github.com/owner/repo/pull/99.patch",
"issue_url": "https://api.github.com/repos/owner/repo/issues/99",
"commits_url": "https://api.github.com/repos/owner/repo/pulls/99/commits",
"review_comments_url": "https://api.github.com/repos/owner/repo/pulls/99/comments",
"review_comment_url": "https://api.github.com/repos/owner/repo/pulls/comments{/number}",
"comments_url": "https://api.github.com/repos/owner/repo/issues/99/comments",
"statuses_url": "https://api.github.com/repos/owner/repo/statuses/def456",
"number": 99,
"state": "open",
"locked": false,
"maintainer_can_modify": false,
"title": "old feature",
"user": author_fixture("prauthor", 2),
"body": null,
"labels": [],
"assignees": [],
"requested_reviewers": [],
"requested_teams": [],
"created_at": "2026-05-01T10:00:00Z",
"updated_at": "2026-05-27T10:00:00Z",
"merged": false,
"head": {
"ref": "feat/old",
"sha": "def456",
"label": "owner:feat/old",
"user": author_fixture("owner", 1),
"repo": null
},
"base": {
"ref": "main",
"sha": "000000",
"label": "owner:main",
"user": author_fixture("owner", 1),
"repo": null
},
"_links": {
"self": { "href": "https://api.github.com/repos/owner/repo/pulls/99" },
"html": { "href": "https://github.com/owner/repo/pull/99" }
},
"author_association": "NONE",
"additions": 0,
"deletions": 0,
"changed_files": 0,
"commits": 1,
"review_comments": 0,
"comments": 0
})))
.mount(&mock_server)
.await;
let provider = GitHubProvider::new_with_base_url(
GithubConfig::default(),
mock_server.uri(),
cache_file,
);
let result = provider.list_prs().await.unwrap();
assert_eq!(result.len(), 1, "Expected exactly 1 re-review PR");
assert_eq!(result[0].number, 99);
assert!(
matches!(result[0].review_status, ReviewStatus::InReview),
"Expected ReviewStatus::InReview, got {:?}",
result[0].review_status
);
}
#[tokio::test]
async fn cache_hit_skips_api_call() {
let temp_dir = tempfile::TempDir::new().expect("TempDir failed");
let cache_file = temp_dir.path().join("github.json");
let now = chrono::Utc::now().to_rfc3339();
let cached_pr_json = json!({
"fetched_at": now,
"ttl_seconds": 60,
"data": [{
"id": {
"provider": "github",
"owner": "owner",
"repo": "repo",
"number": 7
},
"number": 7,
"title": "cached pr",
"url": "https://github.com/owner/repo/pull/7",
"author": {
"login": "cacheduser",
"display_name": null,
"avatar_url": null
},
"reviewers": [],
"repo_full_name": "owner/repo",
"provider": "github",
"head_branch": "",
"base_branch": "",
"state": "Open",
"review_status": "NeedsReview",
"ci_status": null,
"draft": false,
"created_at": "2026-05-27T10:00:00Z",
"updated_at": "2026-05-27T10:00:00Z",
"labels": [],
"comment_count": 0,
"additions": null,
"deletions": null
}]
});
std::fs::write(&cache_file, cached_pr_json.to_string()).expect("Failed to write cache file");
let mock_server = MockServer::start().await;
let provider = GitHubProvider::new_with_base_url(
GithubConfig::default(),
mock_server.uri(),
cache_file,
);
let result = provider.list_prs().await.unwrap();
assert_eq!(result.len(), 1, "Expected 1 cached PR");
assert_eq!(result[0].number, 7, "Expected PR number 7 from cache");
assert_eq!(result[0].title, "cached pr");
let received = mock_server.received_requests().await.unwrap();
assert!(
received.is_empty(),
"Expected 0 API calls on cache hit, got: {} request(s)",
received.len()
);
}