use super::*;
use crate::auth::InstallationId;
use crate::client::ClientConfig;
use crate::error::ApiError;
use chrono::{Duration, Utc};
use std::sync::Arc;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[path = "test_helpers.rs"]
mod test_helpers;
use test_helpers::MockAuthProvider;
mod construction_tests {
use super::*;
#[test]
fn test_installation_client_creation() {
let auth = MockAuthProvider::new_with_token("test-token");
let github_client = GitHubClient::builder(auth).build().unwrap();
let installation_id = InstallationId::new(98765);
let client = InstallationClient::new(Arc::new(github_client), installation_id);
assert_eq!(client.installation_id(), installation_id);
}
#[test]
fn test_installation_id_accessor() {
let auth = MockAuthProvider::new_with_token("test-token");
let github_client = GitHubClient::builder(auth).build().unwrap();
let installation_id = InstallationId::new(54321);
let client = InstallationClient::new(Arc::new(github_client), installation_id);
assert_eq!(client.installation_id(), InstallationId::new(54321));
}
#[tokio::test]
async fn test_github_client_installation_by_id() {
let auth = MockAuthProvider::new_with_token("test-token");
let github_client = GitHubClient::builder(auth).build().unwrap();
let installation_id = InstallationId::new(12345);
let result = github_client.installation_by_id(installation_id).await;
assert!(result.is_ok());
let client = result.unwrap();
assert_eq!(client.installation_id(), installation_id);
}
}
mod http_request_tests {
use super::*;
#[tokio::test]
async fn test_get_request() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_installation_token";
Mock::given(method("GET"))
.and(path("/repos/octocat/Hello-World"))
.and(header("Authorization", format!("Bearer {}", test_token)))
.and(header("Accept", "application/vnd.github+json"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 1296269,
"name": "Hello-World"
})))
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("repos/octocat/Hello-World").await;
assert!(response.is_ok());
let response = response.unwrap();
assert!(response.status().is_success());
}
#[tokio::test]
async fn test_post_request() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
Mock::given(method("POST"))
.and(path("/repos/octocat/Hello-World/issues"))
.and(header("Authorization", format!("Bearer {}", test_token)))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": 1,
"number": 42
})))
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let body = serde_json::json!({"title": "Bug report"});
let response = client.post("repos/octocat/Hello-World/issues", &body).await;
assert!(response.is_ok());
let response = response.unwrap();
assert_eq!(response.status(), 201);
}
#[tokio::test]
async fn test_put_request() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
Mock::given(method("PUT"))
.and(path("/repos/octocat/Hello-World/subscription"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let body = serde_json::json!({"subscribed": true});
let response = client
.put("repos/octocat/Hello-World/subscription", &body)
.await;
assert!(response.is_ok());
assert!(response.unwrap().status().is_success());
}
#[tokio::test]
async fn test_delete_request() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
Mock::given(method("DELETE"))
.and(path("/repos/octocat/Hello-World/subscription"))
.and(header("Authorization", format!("Bearer {}", test_token)))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client
.delete("repos/octocat/Hello-World/subscription")
.await;
assert!(response.is_ok());
assert_eq!(response.unwrap().status(), 204);
}
#[tokio::test]
async fn test_patch_request() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
Mock::given(method("PATCH"))
.and(path("/repos/octocat/Hello-World/issues/1"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let body = serde_json::json!({"state": "closed"});
let response = client
.patch("repos/octocat/Hello-World/issues/1", &body)
.await;
assert!(response.is_ok());
assert!(response.unwrap().status().is_success());
}
}
mod path_normalization_tests {
use super::*;
#[tokio::test]
async fn test_path_with_leading_slash() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
Mock::given(method("GET"))
.and(path("/repos/octocat/Hello-World"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("/repos/octocat/Hello-World").await;
assert!(response.is_ok());
}
#[tokio::test]
async fn test_path_without_leading_slash() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
Mock::given(method("GET"))
.and(path("/repos/octocat/Hello-World"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("repos/octocat/Hello-World").await;
assert!(response.is_ok());
}
}
mod token_management_tests {
use super::*;
#[tokio::test]
async fn test_installation_token_retrieval() {
let mock_server = MockServer::start().await;
let expected_token = "ghs_specific_installation_token";
Mock::given(method("GET"))
.and(path("/test"))
.and(header(
"Authorization",
format!("Bearer {}", expected_token),
))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(expected_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_ok());
}
#[tokio::test]
async fn test_token_error_propagation() {
let auth = MockAuthProvider::new_with_error("Token generation failed");
let github_client = GitHubClient::builder(auth).build().unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_err());
match response.unwrap_err() {
ApiError::TokenGenerationFailed { .. } => {
}
other => panic!("Expected TokenGenerationFailed, got: {:?}", other),
}
}
}
mod authorization_header_tests {
use super::*;
#[tokio::test]
async fn test_bearer_token_header() {
let mock_server = MockServer::start().await;
let test_token = "ghs_installation_token_123";
Mock::given(method("GET"))
.and(path("/test"))
.and(header("Authorization", format!("Bearer {}", test_token)))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let _response = client.get("test").await.unwrap();
}
#[tokio::test]
async fn test_accept_header() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/test"))
.and(header("Accept", "application/vnd.github+json"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token("test-token");
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let _response = client.get("test").await.unwrap();
}
#[tokio::test]
async fn test_user_agent_header() {
let mock_server = MockServer::start().await;
let custom_user_agent = "my-bot/1.0.0";
Mock::given(method("GET"))
.and(path("/test"))
.and(header("User-Agent", custom_user_agent))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token("test-token");
let github_client = GitHubClient::builder(auth)
.config(
ClientConfig::default()
.with_github_api_url(mock_server.uri())
.with_user_agent(custom_user_agent),
)
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let _response = client.get("test").await.unwrap();
}
}
mod retry_logic_tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc as StdArc;
#[tokio::test]
async fn test_retry_on_500_error_succeeds() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
ResponseTemplate::new(500).set_body_string("Internal Server Error")
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true}))
}
})
.expect(2)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_ok());
assert_eq!(response.unwrap().status(), 200);
assert_eq!(attempt_counter.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_retry_on_503_error_succeeds() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt < 2 {
ResponseTemplate::new(503).set_body_string("Service Unavailable")
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true}))
}
})
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_ok());
assert_eq!(response.unwrap().status(), 200);
assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_max_retries_exceeded() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
counter_clone.fetch_add(1, Ordering::SeqCst);
ResponseTemplate::new(500).set_body_string("Internal Server Error")
})
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(
ClientConfig::default()
.with_github_api_url(mock_server.uri())
.with_max_retries(3),
)
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_err());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn test_non_retryable_404_fails_immediately() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
counter_clone.fetch_add(1, Ordering::SeqCst);
ResponseTemplate::new(404).set_body_string("Not Found")
})
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_err());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_non_retryable_401_fails_immediately() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
counter_clone.fetch_add(1, Ordering::SeqCst);
ResponseTemplate::new(401).set_body_string("Unauthorized")
})
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_err());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_429_with_retry_after_header() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
ResponseTemplate::new(429)
.insert_header("Retry-After", "2")
.set_body_json(serde_json::json!({
"message": "API rate limit exceeded"
}))
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true}))
}
})
.expect(2)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let start = std::time::Instant::now();
let response = client.get("test").await;
let elapsed = start.elapsed();
assert!(response.is_ok());
assert_eq!(response.unwrap().status(), 200);
assert_eq!(attempt_counter.load(Ordering::SeqCst), 2);
assert!(elapsed.as_secs() >= 1);
}
#[tokio::test]
async fn test_403_secondary_rate_limit_retry() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
ResponseTemplate::new(403).set_body_json(serde_json::json!({
"message": "You have exceeded a secondary rate limit. Please wait a few minutes before you try again."
}))
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true}))
}
})
.expect(2)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_ok());
assert_eq!(response.unwrap().status(), 200);
assert_eq!(attempt_counter.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_403_permission_denied_fails_immediately() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
counter_clone.fetch_add(1, Ordering::SeqCst);
ResponseTemplate::new(403).set_body_json(serde_json::json!({
"message": "Resource not accessible by integration"
}))
})
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_err());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_post_request_retry() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("POST"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
ResponseTemplate::new(502).set_body_string("Bad Gateway")
} else {
ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 42}))
}
})
.expect(2)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let body = serde_json::json!({"data": "test"});
let response = client.post("test", &body).await;
assert!(response.is_ok());
assert_eq!(response.unwrap().status(), 201);
assert_eq!(attempt_counter.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_put_request_retry() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("PUT"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
ResponseTemplate::new(500)
} else {
ResponseTemplate::new(200)
}
})
.expect(2)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let body = serde_json::json!({"data": "test"});
let response = client.put("test", &body).await;
assert!(response.is_ok());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_delete_request_retry() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("DELETE"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
ResponseTemplate::new(503)
} else {
ResponseTemplate::new(204)
}
})
.expect(2)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.delete("test").await;
assert!(response.is_ok());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_patch_request_retry() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("PATCH"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
ResponseTemplate::new(500)
} else {
ResponseTemplate::new(200)
}
})
.expect(2)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let body = serde_json::json!({"data": "test"});
let response = client.patch("test", &body).await;
assert!(response.is_ok());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 2);
}
}
mod post_graphql_tests {
use super::*;
async fn make_client(mock_server: &MockServer, token: &str) -> InstallationClient {
let auth = MockAuthProvider::new_with_token(token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
github_client
.installation_by_id(InstallationId::new(12345))
.await
.unwrap()
}
#[tokio::test]
async fn test_post_graphql_success() {
let mock_server = MockServer::start().await;
let token = "ghs_graphql_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.and(header("Authorization", format!("Bearer {}", token)))
.and(header("Accept", "application/vnd.github+json"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": { "foo": "bar" }
})))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client.post_graphql("{ foo }", serde_json::json!({})).await;
assert!(result.is_ok());
let data = result.unwrap();
assert_eq!(data["foo"], "bar");
}
#[tokio::test]
async fn test_post_graphql_graphql_error() {
let mock_server = MockServer::start().await;
let token = "ghs_graphql_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"errors": [
{ "message": "Field does not exist on type 'Query'" }
]
})))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.post_graphql("{ nonexistentField }", serde_json::json!({}))
.await;
assert!(result.is_err());
match result.unwrap_err() {
ApiError::GraphQlError { message } => {
assert_eq!(message, "Field does not exist on type 'Query'");
}
other => panic!("Expected GraphQlError, got: {:?}", other),
}
}
#[tokio::test]
async fn test_post_graphql_http_401() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, "bad-token").await;
let result = client
.post_graphql("{ viewer { login } }", serde_json::json!({}))
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ApiError::AuthenticationFailed
));
}
#[tokio::test]
async fn test_post_graphql_uses_bearer_token() {
let mock_server = MockServer::start().await;
let token = "ghs_specific_graphql_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.and(header("Authorization", format!("Bearer {}", token)))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": {}
})))
.expect(1)
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let _ = client
.post_graphql("{ viewer { login } }", serde_json::json!({}))
.await
.unwrap();
}
#[tokio::test]
async fn test_post_graphql_http_500_retries() {
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc as StdArc;
let mock_server = MockServer::start().await;
let token = "ghs_graphql_token";
let counter = StdArc::new(AtomicU32::new(0));
let counter_clone = counter.clone();
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
ResponseTemplate::new(500).set_body_string("Internal Server Error")
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": { "ok": true }
}))
}
})
.expect(2)
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.post_graphql("{ viewer { login } }", serde_json::json!({}))
.await;
assert!(result.is_ok());
assert_eq!(counter.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_post_graphql_not_found_error() {
let mock_server = MockServer::start().await;
let token = "ghs_graphql_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"errors": [
{
"type": "NOT_FOUND",
"message": "Could not resolve to a Repository with the name 'owner/repo'."
}
]
})))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.post_graphql(
"{ repository(owner: \"owner\", name: \"repo\") { id } }",
serde_json::json!({}),
)
.await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ApiError::NotFound),
"Expected ApiError::NotFound for GraphQL NOT_FOUND error type"
);
}
#[tokio::test]
async fn test_post_graphql_error_type_without_message() {
let mock_server = MockServer::start().await;
let token = "ghs_graphql_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"errors": [{ "type": "FORBIDDEN" }],
"data": { "viewer": { "login": "octocat" } }
})))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.post_graphql("{ viewer { login } }", serde_json::json!({}))
.await;
assert!(result.is_err());
match result.unwrap_err() {
ApiError::GraphQlError { message } => {
assert!(
message.contains("errors"),
"expected message to mention 'errors', got: {message}"
);
}
other => panic!(
"Expected GraphQlError for typed error without message, got: {:?}",
other
),
}
}
#[tokio::test]
async fn test_post_graphql_missing_data_field() {
let mock_server = MockServer::start().await;
let token = "ghs_graphql_token";
Mock::given(method("POST"))
.and(path("/graphql"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&mock_server)
.await;
let client = make_client(&mock_server, token).await;
let result = client
.post_graphql("{ viewer { login } }", serde_json::json!({}))
.await;
assert!(result.is_err());
match result.unwrap_err() {
ApiError::GraphQlError { message } => {
assert!(
message.contains("data"),
"expected error message to mention 'data', got: {message}"
);
}
other => panic!(
"Expected GraphQlError for missing data field, got: {:?}",
other
),
}
}
}
mod rate_limit_integration_tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc as StdArc;
#[tokio::test]
async fn test_exponential_backoff_progression() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt < 3 {
ResponseTemplate::new(500).set_body_string("Internal Server Error")
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true}))
}
})
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(
ClientConfig::default()
.with_github_api_url(mock_server.uri())
.with_max_retries(5), )
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let start = std::time::Instant::now();
let response = client.get("test").await;
let elapsed = start.elapsed();
assert!(response.is_ok());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 4);
assert!(elapsed.as_millis() >= 500); }
#[tokio::test]
async fn test_429_respects_retry_after_header_integration() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
ResponseTemplate::new(429)
.insert_header("Retry-After", "2")
.set_body_json(serde_json::json!({
"message": "API rate limit exceeded"
}))
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true}))
}
})
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let start = std::time::Instant::now();
let response = client.get("test").await;
let elapsed = start.elapsed();
assert!(response.is_ok());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 2);
assert!(elapsed.as_secs() >= 1);
}
#[tokio::test]
async fn test_secondary_rate_limit_handling_integration() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
ResponseTemplate::new(403).set_body_json(serde_json::json!({
"message": "You have exceeded a secondary rate limit. Please wait a few minutes before you try again.",
"documentation_url": "https://docs.github.com/en/rest/overview/resources-in-the-rest-api#secondary-rate-limits"
}))
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true}))
}
})
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_ok());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_complete_rate_limit_lifecycle() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let request_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = request_counter.clone();
let initial_reset = Utc::now() + Duration::hours(1);
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let count = counter_clone.fetch_add(1, Ordering::SeqCst);
match count {
0 => ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"status": "healthy"}))
.insert_header("X-RateLimit-Limit", "5000")
.insert_header("X-RateLimit-Remaining", "4500")
.insert_header("X-RateLimit-Reset", initial_reset.timestamp().to_string()),
1 => ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"status": "approaching"}))
.insert_header("X-RateLimit-Limit", "5000")
.insert_header("X-RateLimit-Remaining", "100")
.insert_header("X-RateLimit-Reset", initial_reset.timestamp().to_string()),
2 => ResponseTemplate::new(429)
.insert_header("Retry-After", "1")
.set_body_json(serde_json::json!({
"message": "API rate limit exceeded"
})),
_ => {
let new_reset = Utc::now() + Duration::hours(1);
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"status": "recovered"}))
.insert_header("X-RateLimit-Limit", "5000")
.insert_header("X-RateLimit-Remaining", "5000")
.insert_header("X-RateLimit-Reset", new_reset.timestamp().to_string())
}
}
})
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_ok());
let response = client.get("test").await;
assert!(response.is_ok());
let response = client.get("test").await;
assert!(response.is_ok());
assert_eq!(request_counter.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn test_429_without_retry_after_uses_default_delay() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
ResponseTemplate::new(429).set_body_json(serde_json::json!({
"message": "API rate limit exceeded"
}))
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true}))
}
})
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let start = std::time::Instant::now();
let response = client.get("test").await;
let elapsed = start.elapsed();
assert!(response.is_ok());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 2);
assert!(elapsed.as_secs() >= 45); }
#[tokio::test]
async fn test_handles_malformed_rate_limit_headers() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"success": true}))
.insert_header("X-RateLimit-Limit", "not-a-number")
.insert_header("X-RateLimit-Remaining", "invalid")
.insert_header("X-RateLimit-Reset", "bad-timestamp"),
)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_ok());
}
#[tokio::test]
async fn test_multiple_consecutive_retries() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let attempt_counter = StdArc::new(AtomicU32::new(0));
let counter_clone = attempt_counter.clone();
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(move |_req: &wiremock::Request| {
let attempt = counter_clone.fetch_add(1, Ordering::SeqCst);
if attempt < 4 {
ResponseTemplate::new(502).set_body_string("Bad Gateway")
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true}))
}
})
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(
ClientConfig::default()
.with_github_api_url(mock_server.uri())
.with_max_retries(5), )
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let response = client.get("test").await;
assert!(response.is_ok());
assert_eq!(attempt_counter.load(Ordering::SeqCst), 5);
}
#[tokio::test]
async fn test_concurrent_requests_same_installation() {
let mock_server = MockServer::start().await;
let test_token = "ghs_test_token";
let reset_time = Utc::now() + Duration::hours(1);
Mock::given(method("GET"))
.and(path("/test"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"success": true}))
.insert_header("X-RateLimit-Limit", "5000")
.insert_header("X-RateLimit-Remaining", "4995")
.insert_header("X-RateLimit-Reset", reset_time.timestamp().to_string()),
)
.mount(&mock_server)
.await;
let auth = MockAuthProvider::new_with_token(test_token);
let github_client = GitHubClient::builder(auth)
.config(ClientConfig::default().with_github_api_url(mock_server.uri()))
.build()
.unwrap();
let installation_id = InstallationId::new(12345);
let client = github_client
.installation_by_id(installation_id)
.await
.unwrap();
let mut handles = vec![];
for _ in 0..5 {
let client_clone = client.clone();
let handle = tokio::spawn(async move { client_clone.get("test").await });
handles.push(handle);
}
for handle in handles {
let result = handle.await.unwrap();
assert!(result.is_ok());
}
}
}