test: add HTTP mock tests for github.rs and ai_chat.rs to fix coverage
Coverage was 83.83% (below 85% threshold). Added mockito-based HTTP mock tests for all OAuth device flow functions, list/create repo, get user, and send_chat. Coverage now at 90.26%. - github.rs: 55.99% → 94.85% (added mock tests for all HTTP functions) - ai_chat.rs: 63.27% → 95.70% (added mock tests for send_chat paths) - Added mockito = "1" as dev-dependency - Refactored HTTP functions to accept configurable base URLs for testability
This commit is contained in:
88
src-tauri/Cargo.lock
generated
88
src-tauri/Cargo.lock
generated
@@ -114,6 +114,16 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -441,6 +451,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -1546,6 +1565,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@@ -1560,6 +1585,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -1937,6 +1963,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"gray_matter",
|
||||
"log",
|
||||
"mockito",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -2130,6 +2157,31 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mockito"
|
||||
version = "1.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"bytes",
|
||||
"colored",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"similar",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
@@ -2965,6 +3017,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -2985,6 +3047,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3003,6 +3075,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -3728,6 +3809,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@@ -4483,6 +4570,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
|
||||
@@ -36,3 +36,4 @@ tauri-plugin-process = "2.3.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
mockito = "1"
|
||||
|
||||
@@ -70,12 +70,20 @@ fn extract_response_text(resp: &AnthropicResponse) -> String {
|
||||
|
||||
pub async fn send_chat(req: AiChatRequest) -> Result<AiChatResponse, String> {
|
||||
let api_key = get_api_key()?;
|
||||
send_chat_with_base(req, "https://api.anthropic.com", &api_key).await
|
||||
}
|
||||
|
||||
async fn send_chat_with_base(
|
||||
req: AiChatRequest,
|
||||
api_base: &str,
|
||||
api_key: &str,
|
||||
) -> Result<AiChatResponse, String> {
|
||||
let anthropic_req = build_request(&req);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.anthropic.com/v1/messages")
|
||||
.header("x-api-key", &api_key)
|
||||
.post(format!("{}/v1/messages", api_base))
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&anthropic_req)
|
||||
@@ -105,6 +113,8 @@ pub async fn send_chat(req: AiChatRequest) -> Result<AiChatResponse, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Pure logic tests ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_build_request_defaults() {
|
||||
let req = AiChatRequest {
|
||||
@@ -153,4 +163,222 @@ mod tests {
|
||||
};
|
||||
assert_eq!(extract_response_text(&resp), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_response_text_empty() {
|
||||
let resp = AnthropicResponse {
|
||||
content: vec![],
|
||||
model: "test".to_string(),
|
||||
stop_reason: None,
|
||||
};
|
||||
assert_eq!(extract_response_text(&resp), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_response_text_all_none() {
|
||||
let resp = AnthropicResponse {
|
||||
content: vec![
|
||||
ContentBlock { text: None },
|
||||
ContentBlock { text: None },
|
||||
],
|
||||
model: "test".to_string(),
|
||||
stop_reason: None,
|
||||
};
|
||||
assert_eq!(extract_response_text(&resp), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_api_key_missing() {
|
||||
// Temporarily clear the env var
|
||||
let prev = std::env::var("ANTHROPIC_API_KEY").ok();
|
||||
unsafe {
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
}
|
||||
let result = get_api_key();
|
||||
// Restore
|
||||
if let Some(val) = prev {
|
||||
unsafe {
|
||||
std::env::set_var("ANTHROPIC_API_KEY", val);
|
||||
}
|
||||
}
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.contains("ANTHROPIC_API_KEY environment variable not set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_api_key_present() {
|
||||
let prev = std::env::var("ANTHROPIC_API_KEY").ok();
|
||||
unsafe {
|
||||
std::env::set_var("ANTHROPIC_API_KEY", "sk-test-key-123");
|
||||
}
|
||||
let result = get_api_key();
|
||||
if let Some(val) = prev {
|
||||
unsafe {
|
||||
std::env::set_var("ANTHROPIC_API_KEY", val);
|
||||
}
|
||||
} else {
|
||||
unsafe {
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
}
|
||||
}
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "sk-test-key-123");
|
||||
}
|
||||
|
||||
// ── HTTP mock tests ──────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_chat_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/v1/messages")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"msg_01","type":"message","role":"assistant","content":[{"type":"text","text":"Hello there!"}],"model":"claude-3-5-haiku-20241022","stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let req = AiChatRequest {
|
||||
model: None,
|
||||
messages: vec![AiMessage {
|
||||
role: "user".to_string(),
|
||||
content: "Say hello".to_string(),
|
||||
}],
|
||||
system: None,
|
||||
max_tokens: None,
|
||||
};
|
||||
|
||||
let result = send_chat_with_base(req, &server.url(), "sk-test-key").await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let resp = result.unwrap();
|
||||
assert_eq!(resp.content, "Hello there!");
|
||||
assert_eq!(resp.model, "claude-3-5-haiku-20241022");
|
||||
assert_eq!(resp.stop_reason, Some("end_turn".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_chat_with_system_prompt() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/v1/messages")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"msg_02","type":"message","role":"assistant","content":[{"type":"text","text":"I am a helpful assistant."}],"model":"claude-sonnet-4-20250514","stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":8}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let req = AiChatRequest {
|
||||
model: Some("claude-sonnet-4-20250514".to_string()),
|
||||
messages: vec![AiMessage {
|
||||
role: "user".to_string(),
|
||||
content: "Who are you?".to_string(),
|
||||
}],
|
||||
system: Some("You are a helpful assistant.".to_string()),
|
||||
max_tokens: Some(512),
|
||||
};
|
||||
|
||||
let result = send_chat_with_base(req, &server.url(), "sk-key").await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let resp = result.unwrap();
|
||||
assert_eq!(resp.model, "claude-sonnet-4-20250514");
|
||||
assert_eq!(resp.content, "I am a helpful assistant.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_chat_api_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/v1/messages")
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"type":"error","error":{"type":"authentication_error","message":"Invalid API key"}}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let req = AiChatRequest {
|
||||
model: None,
|
||||
messages: vec![AiMessage {
|
||||
role: "user".to_string(),
|
||||
content: "Hello".to_string(),
|
||||
}],
|
||||
system: None,
|
||||
max_tokens: None,
|
||||
};
|
||||
|
||||
let result = send_chat_with_base(req, &server.url(), "bad-key").await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("Anthropic API error") && err.contains("401"),
|
||||
"unexpected error: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_chat_rate_limit() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/v1/messages")
|
||||
.with_status(429)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"type":"error","error":{"type":"rate_limit_error","message":"Rate limit exceeded"}}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let req = AiChatRequest {
|
||||
model: None,
|
||||
messages: vec![],
|
||||
system: None,
|
||||
max_tokens: None,
|
||||
};
|
||||
|
||||
let result = send_chat_with_base(req, &server.url(), "sk-key").await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("Anthropic API error") && err.contains("429"),
|
||||
"unexpected error: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_chat_multiple_content_blocks() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/v1/messages")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"id":"msg_03","type":"message","role":"assistant","content":[{"type":"text","text":"Part one. "},{"type":"text","text":"Part two."}],"model":"claude-3-5-haiku-20241022","stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":5,"output_tokens":10}}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let req = AiChatRequest {
|
||||
model: None,
|
||||
messages: vec![AiMessage {
|
||||
role: "user".to_string(),
|
||||
content: "Give me two parts".to_string(),
|
||||
}],
|
||||
system: None,
|
||||
max_tokens: None,
|
||||
};
|
||||
|
||||
let result = send_chat_with_base(req, &server.url(), "sk-key").await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().content, "Part one. Part two.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,21 @@ pub struct GithubRepo {
|
||||
|
||||
/// Lists the authenticated user's GitHub repositories.
|
||||
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
github_list_repos_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_list_repos_with_base(
|
||||
token: &str,
|
||||
api_base: &str,
|
||||
) -> Result<Vec<GithubRepo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut all_repos: Vec<GithubRepo> = Vec::new();
|
||||
let mut page = 1u32;
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"https://api.github.com/user/repos?per_page=100&sort=updated&page={}",
|
||||
page
|
||||
"{}/user/repos?per_page=100&sort=updated&page={}",
|
||||
api_base, page
|
||||
);
|
||||
let response = client
|
||||
.get(&url)
|
||||
@@ -79,6 +86,15 @@ pub async fn github_create_repo(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github_create_repo_with_base(token, name, private, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_create_repo_with_base(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
api_base: &str,
|
||||
) -> Result<GithubRepo, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
@@ -89,7 +105,7 @@ pub async fn github_create_repo(
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post("https://api.github.com/user/repos")
|
||||
.post(format!("{}/user/repos", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
@@ -151,9 +167,13 @@ pub struct GitHubUser {
|
||||
|
||||
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github_device_flow_start_with_base("https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://github.com/login/device/code")
|
||||
.post(format!("{}/login/device/code", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
|
||||
.send()
|
||||
@@ -173,9 +193,16 @@ pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
|
||||
/// Polls GitHub for the device flow authorization result.
|
||||
pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPollResult, String> {
|
||||
github_device_flow_poll_with_base(device_code, "https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_poll_with_base(
|
||||
device_code: &str,
|
||||
base_url: &str,
|
||||
) -> Result<DeviceFlowPollResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://github.com/login/oauth/access_token")
|
||||
.post(format!("{}/login/oauth/access_token", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.form(&[
|
||||
("client_id", GITHUB_CLIENT_ID),
|
||||
@@ -228,9 +255,13 @@ pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPoll
|
||||
|
||||
/// Gets the authenticated GitHub user's profile.
|
||||
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
|
||||
github_get_user_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get("https://api.github.com/user")
|
||||
.get(format!("{}/user", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
@@ -339,6 +370,8 @@ mod tests {
|
||||
use super::*;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
// ── Sync/pure logic tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_basic_github_url() {
|
||||
let url = "https://github.com/user/repo.git";
|
||||
@@ -463,6 +496,8 @@ mod tests {
|
||||
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
|
||||
}
|
||||
|
||||
// ── Serialization/struct tests ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_serialization() {
|
||||
let repo = GithubRepo {
|
||||
@@ -535,10 +570,7 @@ mod tests {
|
||||
let parsed: DeviceFlowPollResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.status, "pending");
|
||||
assert!(parsed.access_token.is_none());
|
||||
assert_eq!(
|
||||
parsed.error,
|
||||
Some("authorization_pending".to_string())
|
||||
);
|
||||
assert_eq!(parsed.error, Some("authorization_pending".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -561,4 +593,405 @@ mod tests {
|
||||
assert_eq!(user.login, "bot");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
|
||||
// ── HTTP mock tests ──────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = github_list_repos_with_base("token123", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let repos = result.unwrap();
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].name, "my-repo");
|
||||
assert_eq!(repos[0].full_name, "user/my-repo");
|
||||
assert!(!repos[0].private);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_empty() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("[]")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = github_list_repos_with_base("token123", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_auth_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"Bad credentials"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = github_list_repos_with_base("bad_token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("GitHub API error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_paginated() {
|
||||
// Return 100 repos on page 1 (triggers pagination), then fewer on page 2
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
// Build 100 repos for page 1
|
||||
let repos_page1: Vec<serde_json::Value> = (0..100)
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"name": format!("repo-{}", i),
|
||||
"full_name": format!("user/repo-{}", i),
|
||||
"description": null,
|
||||
"private": false,
|
||||
"clone_url": format!("https://github.com/user/repo-{}.git", i),
|
||||
"html_url": format!("https://github.com/user/repo-{}", i),
|
||||
"updated_at": null
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mock1 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::to_string(&repos_page1).unwrap())
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let mock2 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = github_list_repos_with_base("token", &server.url()).await;
|
||||
mock1.assert_async().await;
|
||||
mock2.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let repos = result.unwrap();
|
||||
assert_eq!(repos.len(), 101);
|
||||
assert_eq!(repos[100].name, "extra-repo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/user/repos")
|
||||
.with_status(201)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result =
|
||||
github_create_repo_with_base("token", "new-repo", true, &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let repo = result.unwrap();
|
||||
assert_eq!(repo.name, "new-repo");
|
||||
assert!(repo.private);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_name_exists() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/user/repos")
|
||||
.with_status(422)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result =
|
||||
github_create_repo_with_base("token", "existing-repo", false, &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.contains("Repository name already exists"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_server_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/user/repos")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"Internal Server Error"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result =
|
||||
github_create_repo_with_base("token", "new-repo", false, &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("GitHub API error 500"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/device/code")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = github_device_flow_start_with_base(&server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let start = result.unwrap();
|
||||
assert_eq!(start.device_code, "dev_abc");
|
||||
assert_eq!(start.user_code, "ABCD-1234");
|
||||
assert_eq!(start.expires_in, 900);
|
||||
assert_eq!(start.interval, 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/device/code")
|
||||
.with_status(400)
|
||||
.with_body("bad request")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = github_device_flow_start_with_base(&server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Device flow start failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_complete() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result =
|
||||
github_device_flow_poll_with_base("dev_code_xyz", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let poll = result.unwrap();
|
||||
assert_eq!(poll.status, "complete");
|
||||
assert_eq!(poll.access_token, Some("gho_secret123".to_string()));
|
||||
assert!(poll.error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_pending() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result =
|
||||
github_device_flow_poll_with_base("dev_code_xyz", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let poll = result.unwrap();
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert!(poll.access_token.is_none());
|
||||
assert_eq!(poll.error, Some("authorization_pending".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_slow_down() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"slow_down"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result =
|
||||
github_device_flow_poll_with_base("dev_code_xyz", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let poll = result.unwrap();
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("slow_down".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_expired() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"expired_token"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result =
|
||||
github_device_flow_poll_with_base("dev_code_xyz", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let poll = result.unwrap();
|
||||
assert_eq!(poll.status, "expired");
|
||||
assert_eq!(poll.error, Some("expired_token".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_other_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":"access_denied"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result =
|
||||
github_device_flow_poll_with_base("dev_code_xyz", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let poll = result.unwrap();
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("access_denied".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_http_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(503)
|
||||
.with_body("Service Unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result =
|
||||
github_device_flow_poll_with_base("dev_code_xyz", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Device flow poll HTTP error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_unknown_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result =
|
||||
github_device_flow_poll_with_base("dev_code_xyz", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let poll = result.unwrap();
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("unknown".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_success() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/user")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = github_get_user_with_base("gho_token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let user = result.unwrap();
|
||||
assert_eq!(user.login, "lucaong");
|
||||
assert_eq!(user.name, Some("Luca Ongaro".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_unauthorized() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/user")
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"Bad credentials"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = github_get_user_with_base("bad_token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("GitHub API error 401"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_null_name() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("GET", "/user")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = github_get_user_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
assert!(result.is_ok());
|
||||
let user = result.unwrap();
|
||||
assert_eq!(user.login, "bot-account");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user