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:
lucaronin
2026-02-23 20:47:09 +01:00
parent 1b22a12dcb
commit 4a26066db2
4 changed files with 762 additions and 12 deletions

88
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

@@ -36,3 +36,4 @@ tauri-plugin-process = "2.3.1"
[dev-dependencies]
tempfile = "3"
mockito = "1"

View File

@@ -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.");
}
}

View File

@@ -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());
}
}