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 889d06b717
commit 1c064731fc
4 changed files with 762 additions and 12 deletions

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