diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 181c32d6..57b78cb2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 153ae755..5e21a6ba 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,3 +36,4 @@ tauri-plugin-process = "2.3.1" [dev-dependencies] tempfile = "3" +mockito = "1" diff --git a/src-tauri/src/ai_chat.rs b/src-tauri/src/ai_chat.rs index b9a3f83a..aee17592 100644 --- a/src-tauri/src/ai_chat.rs +++ b/src-tauri/src/ai_chat.rs @@ -70,12 +70,20 @@ fn extract_response_text(resp: &AnthropicResponse) -> String { pub async fn send_chat(req: AiChatRequest) -> Result { 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 { 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 { 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."); + } } diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs index 6c3ff814..f4227004 100644 --- a/src-tauri/src/github.rs +++ b/src-tauri/src/github.rs @@ -18,14 +18,21 @@ pub struct GithubRepo { /// Lists the authenticated user's GitHub repositories. pub async fn github_list_repos(token: &str) -> Result, 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, String> { let client = reqwest::Client::new(); let mut all_repos: Vec = 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 { + 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 { 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 { + github_device_flow_start_with_base("https://github.com").await +} + +async fn github_device_flow_start_with_base(base_url: &str) -> Result { 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 { /// Polls GitHub for the device flow authorization result. pub async fn github_device_flow_poll(device_code: &str) -> Result { + 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 { 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 Result { + github_get_user_with_base(token, "https://api.github.com").await +} + +async fn github_get_user_with_base(token: &str, api_base: &str) -> Result { 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 = (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()); + } }