Replace mockito with httpmock in fabro-hooks, fabro-openai-oauth, and fabro-tracker

mockito's Server::new_async() triggers macOS SCDynamicStoreCreateWithOptions
via hyper-util (~300ms per test), which serializes on configd under workspace
concurrency and causes 4s+ timeouts. httpmock avoids this path entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-28 20:39:14 -04:00
parent 6eb0c50d75
commit 1b9a6285e4
No known key found for this signature in database
9 changed files with 555 additions and 546 deletions

41
Cargo.lock generated
View file

@ -688,15 +688,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[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 = "concurrent-queue"
version = "2.5.0"
@ -1703,7 +1694,7 @@ dependencies = [
"fabro-model",
"fabro-types",
"fabro-util",
"mockito",
"httpmock",
"regex",
"reqwest",
"serde",
@ -1791,7 +1782,7 @@ dependencies = [
"axum",
"base64",
"hex",
"mockito",
"httpmock",
"open",
"rand 0.8.5",
"reqwest",
@ -1927,7 +1918,7 @@ version = "0.176.2"
dependencies = [
"async-trait",
"fabro-github",
"mockito",
"httpmock",
"reqwest",
"serde_json",
"tokio",
@ -2017,7 +2008,6 @@ dependencies = [
"futures",
"git2",
"hex",
"mockito",
"predicates",
"rand 0.8.5",
"regex",
@ -3579,31 +3569,6 @@ dependencies = [
"parking_lot",
]
[[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 = "naive-timer"
version = "0.2.0"

View file

@ -28,6 +28,6 @@ tracing.workspace = true
tokio-util.workspace = true
[dev-dependencies]
mockito = "1"
httpmock = "0.8"
tokio = { workspace = true, features = ["test-util", "macros"] }
toml.workspace = true

View file

@ -908,19 +908,21 @@ mod tests {
#[tokio::test]
async fn http_hook_posts_json_and_parses_decision() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock("POST", "/hook")
.match_header("content-type", "application/json")
.with_status(200)
.with_body(r#"{"decision": "skip", "reason": "not needed"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/hook")
.header("content-type", "application/json");
then.status(200)
.body(r#"{"decision": "skip", "reason": "not needed"}"#);
})
.await;
let client = test_http_client();
let decision = HookExecutorImpl::execute_http(
&client,
&format!("{}/hook", server.url()),
&server.url("/hook"),
None,
&[],
&TlsMode::Off,
@ -941,18 +943,18 @@ mod tests {
#[tokio::test]
async fn http_hook_empty_2xx_returns_proceed() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock("POST", "/hook")
.with_status(200)
.with_body("")
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/hook");
then.status(200).body("");
})
.await;
let client = test_http_client();
let decision = HookExecutorImpl::execute_http(
&client,
&format!("{}/hook", server.url()),
&server.url("/hook"),
None,
&[],
&TlsMode::Off,
@ -968,18 +970,18 @@ mod tests {
#[tokio::test]
async fn http_hook_non_2xx_returns_proceed() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock("POST", "/hook")
.with_status(500)
.with_body("Internal Server Error")
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/hook");
then.status(500).body("Internal Server Error");
})
.await;
let client = test_http_client();
let decision = HookExecutorImpl::execute_http(
&client,
&format!("{}/hook", server.url()),
&server.url("/hook"),
None,
&[],
&TlsMode::Off,
@ -1015,13 +1017,14 @@ mod tests {
async fn http_hook_sends_interpolated_headers() {
let env = test_env(&[("FABRO_TEST_TOKEN", "my-secret")]);
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock("POST", "/hook")
.match_header("authorization", "Bearer my-secret")
.with_status(200)
.with_body("")
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/hook")
.header("authorization", "Bearer my-secret");
then.status(200).body("");
})
.await;
let headers = HashMap::from([(
@ -1032,7 +1035,7 @@ mod tests {
let client = test_http_client();
let decision = HookExecutorImpl::execute_http(
&client,
&format!("{}/hook", server.url()),
&server.url("/hook"),
Some(&headers),
&["FABRO_TEST_TOKEN".to_string()],
&TlsMode::Off,
@ -1086,18 +1089,18 @@ mod tests {
#[tokio::test]
async fn http_hook_allows_http_url_when_tls_off() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock("POST", "/hook")
.with_status(200)
.with_body("")
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/hook");
then.status(200).body("");
})
.await;
let client = test_http_client();
let decision = HookExecutorImpl::execute_http(
&client,
&format!("{}/hook", server.url()),
&server.url("/hook"),
None,
&[],
&TlsMode::Off,
@ -1113,12 +1116,12 @@ mod tests {
#[tokio::test]
async fn executor_dispatches_http_hook() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock("POST", "/hook")
.with_status(200)
.with_body(r#"{"decision": "proceed"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/hook");
then.status(200).body(r#"{"decision": "proceed"}"#);
})
.await;
let executor = HookExecutorImpl;
@ -1127,7 +1130,7 @@ mod tests {
event: HookEvent::StageStart,
command: None,
hook_type: Some(HookType::Http {
url: format!("{}/hook", server.url()),
url: server.url("/hook"),
headers: None,
allowed_env_vars: vec![],
tls: TlsMode::Off,

View file

@ -25,5 +25,5 @@ axum = "0.8"
open = "5"
[dev-dependencies]
mockito = "1"
httpmock = "0.8"
tokio = { workspace = true, features = ["test-util", "macros"] }

View file

@ -777,45 +777,36 @@ mod tests {
#[tokio::test]
async fn exchange_code_success() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock("POST", "/oauth/token")
.match_header("content-type", "application/x-www-form-urlencoded")
.match_body(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded(
"grant_type".to_string(),
"authorization_code".to_string(),
),
mockito::Matcher::UrlEncoded("client_id".to_string(), "test-client".to_string()),
mockito::Matcher::UrlEncoded("code".to_string(), "test-code".to_string()),
mockito::Matcher::UrlEncoded(
"redirect_uri".to_string(),
"http://localhost/cb".to_string(),
),
mockito::Matcher::UrlEncoded(
"code_verifier".to_string(),
"test-verifier".to_string(),
),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"id_token": "id-tok",
"access_token": "access-tok",
"refresh_token": "refresh-tok",
"expires_in": 3600
})
.to_string(),
)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/oauth/token")
.header("content-type", "application/x-www-form-urlencoded")
.form_urlencoded_tuple("grant_type", "authorization_code")
.form_urlencoded_tuple("client_id", "test-client")
.form_urlencoded_tuple("code", "test-code")
.form_urlencoded_tuple("redirect_uri", "http://localhost/cb")
.form_urlencoded_tuple("code_verifier", "test-verifier");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"id_token": "id-tok",
"access_token": "access-tok",
"refresh_token": "refresh-tok",
"expires_in": 3600
})
.to_string(),
);
})
.await;
let client = reqwest::Client::new();
let tokens = exchange_code_for_tokens(
&client,
&server.url(),
&server.url(""),
"test-client",
"test-code",
"http://localhost/cb",
@ -834,19 +825,19 @@ mod tests {
#[tokio::test]
async fn exchange_code_error_response() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
server
.mock("POST", "/oauth/token")
.with_status(400)
.with_body(r#"{"error": "invalid_grant"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/oauth/token");
then.status(400).body(r#"{"error": "invalid_grant"}"#);
})
.await;
let client = reqwest::Client::new();
let err = exchange_code_for_tokens(
&client,
&server.url(),
&server.url(""),
"test-client",
"bad-code",
"http://localhost/cb",
@ -864,36 +855,34 @@ mod tests {
#[tokio::test]
async fn refresh_token_success() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock("POST", "/oauth/token")
.match_body(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("grant_type".to_string(), "refresh_token".to_string()),
mockito::Matcher::UrlEncoded("client_id".to_string(), "test-client".to_string()),
mockito::Matcher::UrlEncoded(
"refresh_token".to_string(),
"old-refresh-tok".to_string(),
),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"id_token": "new-id",
"access_token": "new-access",
"refresh_token": "new-refresh",
"expires_in": 7200
})
.to_string(),
)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/oauth/token")
.form_urlencoded_tuple("grant_type", "refresh_token")
.form_urlencoded_tuple("client_id", "test-client")
.form_urlencoded_tuple("refresh_token", "old-refresh-tok");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"id_token": "new-id",
"access_token": "new-access",
"refresh_token": "new-refresh",
"expires_in": 7200
})
.to_string(),
);
})
.await;
let client = reqwest::Client::new();
let tokens = refresh_access_token(&client, &server.url(), "test-client", "old-refresh-tok")
.await
.unwrap();
let tokens =
refresh_access_token(&client, &server.url(""), "test-client", "old-refresh-tok")
.await
.unwrap();
assert_eq!(tokens.access_token, "new-access");
assert_eq!(tokens.refresh_token, "new-refresh");
@ -904,17 +893,17 @@ mod tests {
#[tokio::test]
async fn refresh_token_error() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
server
.mock("POST", "/oauth/token")
.with_status(401)
.with_body(r#"{"error": "invalid_token"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/oauth/token");
then.status(401).body(r#"{"error": "invalid_token"}"#);
})
.await;
let client = reqwest::Client::new();
let err = refresh_access_token(&client, &server.url(), "test-client", "expired-tok")
let err = refresh_access_token(&client, &server.url(""), "test-client", "expired-tok")
.await
.unwrap_err();
@ -927,25 +916,27 @@ mod tests {
#[tokio::test]
async fn initiate_device_flow_success() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock("POST", "/api/accounts/deviceauth/usercode")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"device_auth_id": "dev-123",
"user_code": "ABCD-1234",
"interval": 5
})
.to_string(),
)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/api/accounts/deviceauth/usercode");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"device_auth_id": "dev-123",
"user_code": "ABCD-1234",
"interval": 5
})
.to_string(),
);
})
.await;
let client = reqwest::Client::new();
let device = initiate_device_flow(&client, &server.url(), "test-client")
let device = initiate_device_flow(&client, &server.url(""), "test-client")
.await
.unwrap();
@ -958,17 +949,18 @@ mod tests {
#[tokio::test]
async fn initiate_device_flow_error() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
server
.mock("POST", "/api/accounts/deviceauth/usercode")
.with_status(500)
.with_body("Internal Server Error")
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/api/accounts/deviceauth/usercode");
then.status(500).body("Internal Server Error");
})
.await;
let client = reqwest::Client::new();
let err = initiate_device_flow(&client, &server.url(), "test-client")
let err = initiate_device_flow(&client, &server.url(""), "test-client")
.await
.unwrap_err();
@ -977,30 +969,32 @@ mod tests {
#[tokio::test]
async fn poll_device_flow_success() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
server
.mock("POST", "/api/accounts/deviceauth/token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::json!({"code": "auth-code-123"}).to_string())
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/api/accounts/deviceauth/token");
then.status(200)
.header("content-type", "application/json")
.body(serde_json::json!({"code": "auth-code-123"}).to_string());
})
.await;
server
.mock("POST", "/oauth/token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
serde_json::json!({
"id_token": "dev-id",
"access_token": "dev-access",
"refresh_token": "dev-refresh",
"expires_in": 3600
})
.to_string(),
)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/oauth/token");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"id_token": "dev-id",
"access_token": "dev-access",
"refresh_token": "dev-refresh",
"expires_in": 3600
})
.to_string(),
);
})
.await;
let device = DeviceAuthResponse {
@ -1010,7 +1004,7 @@ mod tests {
};
let client = reqwest::Client::new();
let tokens = poll_device_flow(&client, &server.url(), "test-client", &device)
let tokens = poll_device_flow(&client, &server.url(""), "test-client", &device)
.await
.unwrap();
@ -1019,14 +1013,15 @@ mod tests {
#[tokio::test]
async fn poll_device_flow_expired() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
server
.mock("POST", "/api/accounts/deviceauth/token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::json!({"error": "expired_token"}).to_string())
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/api/accounts/deviceauth/token");
then.status(200)
.header("content-type", "application/json")
.body(serde_json::json!({"error": "expired_token"}).to_string());
})
.await;
let device = DeviceAuthResponse {
@ -1036,7 +1031,7 @@ mod tests {
};
let client = reqwest::Client::new();
let err = poll_device_flow(&client, &server.url(), "test-client", &device)
let err = poll_device_flow(&client, &server.url(""), "test-client", &device)
.await
.unwrap_err();

View file

@ -20,5 +20,5 @@ tracing.workspace = true
tokio = { workspace = true }
[dev-dependencies]
mockito = "1"
httpmock = "0.8"
tokio = { workspace = true, features = ["test-util", "macros"] }

View file

@ -521,23 +521,25 @@ mod tests {
#[tokio::test]
async fn execute_github_graphql_success() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock("POST", "/graphql")
.match_header("Authorization", "Bearer test-token")
.match_header("Content-Type", "application/json")
.match_header("User-Agent", "fabro")
.with_status(200)
.with_body(r#"{"data": {"viewer": {"id": "U_abc"}}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.header("Authorization", "Bearer test-token")
.header("Content-Type", "application/json")
.header("User-Agent", "fabro");
then.status(200)
.body(r#"{"data": {"viewer": {"id": "U_abc"}}}"#);
})
.await;
let client = reqwest::Client::new();
let result = execute_github_graphql(
&client,
"test-token",
&format!("{}/graphql", server.url()),
&server.url("/graphql"),
"query { viewer { id } }",
serde_json::json!({}),
)
@ -550,20 +552,20 @@ mod tests {
#[tokio::test]
async fn execute_github_graphql_http_error() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
server
.mock("POST", "/graphql")
.with_status(401)
.with_body("Unauthorized")
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(401).body("Unauthorized");
})
.await;
let client = reqwest::Client::new();
let err = execute_github_graphql(
&client,
"bad-token",
&format!("{}/graphql", server.url()),
&server.url("/graphql"),
"query { viewer { id } }",
serde_json::json!({}),
)
@ -575,20 +577,21 @@ mod tests {
#[tokio::test]
async fn execute_github_graphql_errors_array() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": null, "errors": [{"message": "Not found"}]}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(200)
.body(r#"{"data": null, "errors": [{"message": "Not found"}]}"#);
})
.await;
let client = reqwest::Client::new();
let err = execute_github_graphql(
&client,
"token",
&format!("{}/graphql", server.url()),
&server.url("/graphql"),
"query { bad }",
serde_json::json!({}),
)
@ -600,23 +603,24 @@ mod tests {
#[tokio::test]
async fn execute_github_graphql_correct_headers() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let mock = server
.mock("POST", "/graphql")
.match_header("Authorization", "Bearer my-token")
.match_header("Content-Type", "application/json")
.match_header("User-Agent", "fabro")
.with_status(200)
.with_body(r#"{"data": {}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.header("Authorization", "Bearer my-token")
.header("Content-Type", "application/json")
.header("User-Agent", "fabro");
then.status(200).body(r#"{"data": {}}"#);
})
.await;
let client = reqwest::Client::new();
execute_github_graphql(
&client,
"my-token",
&format!("{}/graphql", server.url()),
&server.url("/graphql"),
"query { viewer { id } }",
serde_json::json!({}),
)
@ -707,33 +711,38 @@ mod tests {
#[tokio::test]
async fn project_node_id_resolved_via_org() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let pem = test_rsa_key();
let tracker = mock_github_tracker(&server.url(), pem);
let tracker = mock_github_tracker(&server.url(""), pem);
server
.mock("GET", "/repos/owner/repo/installation")
.with_status(200)
.with_body(r#"{"id": 1}"#)
.create_async()
.mock_async(|when, then| {
when.method("GET").path("/repos/owner/repo/installation");
then.status(200).body(r#"{"id": 1}"#);
})
.await;
server
.mock("POST", "/app/installations/1/access_tokens")
.with_status(201)
.with_body(r#"{"token": "ghs_test"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(org_project_node_id_response())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("organization(login:");
then.status(200).body(org_project_node_id_response());
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(empty_items_response())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("items(first:");
then.status(200).body(empty_items_response());
})
.await;
let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap();
@ -742,42 +751,50 @@ mod tests {
#[tokio::test]
async fn project_node_id_falls_back_to_user() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let pem = test_rsa_key();
let tracker = mock_github_tracker(&server.url(), pem);
let tracker = mock_github_tracker(&server.url(""), pem);
server
.mock("GET", "/repos/owner/repo/installation")
.with_status(200)
.with_body(r#"{"id": 1}"#)
.create_async()
.mock_async(|when, then| {
when.method("GET").path("/repos/owner/repo/installation");
then.status(200).body(r#"{"id": 1}"#);
})
.await;
server
.mock("POST", "/app/installations/1/access_tokens")
.with_status(201)
.with_body(r#"{"token": "ghs_test"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
})
.await;
// Org query returns null → fall back to user
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"organization": null}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("organization(login:");
then.status(200).body(r#"{"data": {"organization": null}}"#);
})
.await;
// User query succeeds
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"user": {"projectV2": {"id": "PVT_user1"}}}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("user(login:");
then.status(200)
.body(r#"{"data": {"user": {"projectV2": {"id": "PVT_user1"}}}}"#);
})
.await;
// Items page (empty)
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(empty_items_response())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("items(first:");
then.status(200).body(empty_items_response());
})
.await;
let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap();
@ -790,27 +807,29 @@ mod tests {
#[tokio::test]
async fn github_tracker_fetch_viewer_id_success() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let pem = test_rsa_key();
let tracker = mock_github_tracker(&server.url(), pem);
let tracker = mock_github_tracker(&server.url(""), pem);
server
.mock("GET", "/repos/owner/repo/installation")
.with_status(200)
.with_body(r#"{"id": 1}"#)
.create_async()
.mock_async(|when, then| {
when.method("GET").path("/repos/owner/repo/installation");
then.status(200).body(r#"{"id": 1}"#);
})
.await;
server
.mock("POST", "/app/installations/1/access_tokens")
.with_status(201)
.with_body(r#"{"token": "ghs_test"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"viewer": {"id": "U_xyz"}}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(200)
.body(r#"{"data": {"viewer": {"id": "U_xyz"}}}"#);
})
.await;
let id = tracker.fetch_viewer_id().await.unwrap();
@ -823,27 +842,29 @@ mod tests {
#[tokio::test]
async fn github_tracker_create_comment_success() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let pem = test_rsa_key();
let tracker = mock_github_tracker(&server.url(), pem);
let tracker = mock_github_tracker(&server.url(""), pem);
server
.mock("GET", "/repos/owner/repo/installation")
.with_status(200)
.with_body(r#"{"id": 1}"#)
.create_async()
.mock_async(|when, then| {
when.method("GET").path("/repos/owner/repo/installation");
then.status(200).body(r#"{"id": 1}"#);
})
.await;
server
.mock("POST", "/app/installations/1/access_tokens")
.with_status(201)
.with_body(r#"{"token": "ghs_test"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"addComment": {"clientMutationId": null}}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(200)
.body(r#"{"data": {"addComment": {"clientMutationId": null}}}"#);
})
.await;
let issue = make_test_issue("In Progress");
@ -856,43 +877,42 @@ mod tests {
#[tokio::test]
async fn github_tracker_update_issue_state_success() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let pem = test_rsa_key();
let tracker = mock_github_tracker(&server.url(), pem);
let tracker = mock_github_tracker(&server.url(""), pem);
server
.mock("GET", "/repos/owner/repo/installation")
.with_status(200)
.with_body(r#"{"id": 1}"#)
.create_async()
.mock_async(|when, then| {
when.method("GET").path("/repos/owner/repo/installation");
then.status(200).body(r#"{"id": 1}"#);
})
.await;
server
.mock("POST", "/app/installations/1/access_tokens")
.with_status(201)
.with_body(r#"{"token": "ghs_test"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
})
.await;
// Resolve project node ID (org path)
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(org_project_node_id_response())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("organization(login:");
then.status(200).body(org_project_node_id_response());
})
.await;
// Field query
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}, {"id": "opt-todo", "name": "Todo"}]}}}}"#)
.create_async()
.await;
server.mock_async(|when, then| {
when.method("POST").path("/graphql").body_includes("field(name:");
then.status(200).body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}, {"id": "opt-todo", "name": "Todo"}]}}}}"#);
}).await;
// Update mutation
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"updateProjectV2ItemFieldValue": {"projectV2Item": {"id": "PVTI_item1"}}}}"#)
.create_async()
.await;
server.mock_async(|when, then| {
when.method("POST").path("/graphql").body_includes("updateProjectV2ItemFieldValue");
then.status(200).body(r#"{"data": {"updateProjectV2ItemFieldValue": {"projectV2Item": {"id": "PVTI_item1"}}}}"#);
}).await;
let issue = make_test_issue("In Progress");
tracker.update_issue_state(&issue, "Done").await.unwrap();
@ -900,36 +920,37 @@ mod tests {
#[tokio::test]
async fn github_tracker_update_issue_state_status_not_found() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let pem = test_rsa_key();
let tracker = mock_github_tracker(&server.url(), pem);
let tracker = mock_github_tracker(&server.url(""), pem);
server
.mock("GET", "/repos/owner/repo/installation")
.with_status(200)
.with_body(r#"{"id": 1}"#)
.create_async()
.mock_async(|when, then| {
when.method("GET").path("/repos/owner/repo/installation");
then.status(200).body(r#"{"id": 1}"#);
})
.await;
server
.mock("POST", "/app/installations/1/access_tokens")
.with_status(201)
.with_body(r#"{"token": "ghs_test"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
})
.await;
// Resolve project node ID
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(org_project_node_id_response())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("organization(login:");
then.status(200).body(org_project_node_id_response());
})
.await;
// Field query — options don't include "Nonexistent"
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}]}}}}"#)
.create_async()
.await;
server.mock_async(|when, then| {
when.method("POST").path("/graphql").body_includes("field(name:");
then.status(200).body(r#"{"data": {"node": {"field": {"id": "FLD_1", "options": [{"id": "opt-done", "name": "Done"}]}}}}"#);
}).await;
let issue = make_test_issue("Todo");
let err = tracker
@ -946,33 +967,38 @@ mod tests {
#[tokio::test]
async fn github_tracker_fetch_candidate_issues_single_page() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let pem = test_rsa_key();
let tracker = mock_github_tracker(&server.url(), pem);
let tracker = mock_github_tracker(&server.url(""), pem);
server
.mock("GET", "/repos/owner/repo/installation")
.with_status(200)
.with_body(r#"{"id": 1}"#)
.create_async()
.mock_async(|when, then| {
when.method("GET").path("/repos/owner/repo/installation");
then.status(200).body(r#"{"id": 1}"#);
})
.await;
server
.mock("POST", "/app/installations/1/access_tokens")
.with_status(201)
.with_body(r#"{"token": "ghs_test"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(org_project_node_id_response())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("organization(login:");
then.status(200).body(org_project_node_id_response());
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(single_item_response("In Progress"))
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("items(first:");
then.status(200).body(single_item_response("In Progress"));
})
.await;
let issues = tracker
@ -992,33 +1018,38 @@ mod tests {
#[tokio::test]
async fn github_tracker_fetch_candidate_issues_empty() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let pem = test_rsa_key();
let tracker = mock_github_tracker(&server.url(), pem);
let tracker = mock_github_tracker(&server.url(""), pem);
server
.mock("GET", "/repos/owner/repo/installation")
.with_status(200)
.with_body(r#"{"id": 1}"#)
.create_async()
.mock_async(|when, then| {
when.method("GET").path("/repos/owner/repo/installation");
then.status(200).body(r#"{"id": 1}"#);
})
.await;
server
.mock("POST", "/app/installations/1/access_tokens")
.with_status(201)
.with_body(r#"{"token": "ghs_test"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(org_project_node_id_response())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("organization(login:");
then.status(200).body(org_project_node_id_response());
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(empty_items_response())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("items(first:");
then.status(200).body(empty_items_response());
})
.await;
let issues = tracker.fetch_candidate_issues(&["Todo"]).await.unwrap();
@ -1027,9 +1058,9 @@ mod tests {
#[tokio::test]
async fn github_tracker_fetch_candidate_issues_status_filtering() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let pem = test_rsa_key();
let tracker = mock_github_tracker(&server.url(), pem);
let tracker = mock_github_tracker(&server.url(""), pem);
let items_body = serde_json::json!({
"data": {
@ -1065,28 +1096,33 @@ mod tests {
.to_string();
server
.mock("GET", "/repos/owner/repo/installation")
.with_status(200)
.with_body(r#"{"id": 1}"#)
.create_async()
.mock_async(|when, then| {
when.method("GET").path("/repos/owner/repo/installation");
then.status(200).body(r#"{"id": 1}"#);
})
.await;
server
.mock("POST", "/app/installations/1/access_tokens")
.with_status(201)
.with_body(r#"{"token": "ghs_test"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(org_project_node_id_response())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("organization(login:");
then.status(200).body(org_project_node_id_response());
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(items_body)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("items(first:");
then.status(200).body(items_body);
})
.await;
let issues = tracker
@ -1104,9 +1140,9 @@ mod tests {
#[tokio::test]
async fn github_tracker_fetch_issues_by_ids_ordering() {
let mut server = mockito::Server::new_async().await;
let server = httpmock::MockServer::start_async().await;
let pem = test_rsa_key();
let tracker = mock_github_tracker(&server.url(), pem);
let tracker = mock_github_tracker(&server.url(""), pem);
// Page returns issues in reverse order of what we request
let items_body = serde_json::json!({
@ -1143,28 +1179,33 @@ mod tests {
.to_string();
server
.mock("GET", "/repos/owner/repo/installation")
.with_status(200)
.with_body(r#"{"id": 1}"#)
.create_async()
.mock_async(|when, then| {
when.method("GET").path("/repos/owner/repo/installation");
then.status(200).body(r#"{"id": 1}"#);
})
.await;
server
.mock("POST", "/app/installations/1/access_tokens")
.with_status(201)
.with_body(r#"{"token": "ghs_test"}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(org_project_node_id_response())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("organization(login:");
then.status(200).body(org_project_node_id_response());
})
.await;
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(items_body)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("items(first:");
then.status(200).body(items_body);
})
.await;
// Request in A, B order — should get back in A, B order despite page returning B, A

View file

@ -577,16 +577,18 @@ mod tests {
#[tokio::test]
async fn execute_graphql_success() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
let mock = server
.mock("POST", "/graphql")
.match_header("Authorization", "lin_api_test123")
.match_header("Content-Type", "application/json")
.with_status(200)
.with_body(r#"{"data": {"viewer": {"id": "user-1"}}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.header("Authorization", "lin_api_test123")
.header("Content-Type", "application/json");
then.status(200)
.body(r#"{"data": {"viewer": {"id": "user-1"}}}"#);
})
.await;
let client = reqwest::Client::new();
@ -605,14 +607,14 @@ mod tests {
#[tokio::test]
async fn execute_graphql_http_401() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
server
.mock("POST", "/graphql")
.with_status(401)
.with_body("Unauthorized")
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(401).body("Unauthorized");
})
.await;
let client = reqwest::Client::new();
@ -630,14 +632,14 @@ mod tests {
#[tokio::test]
async fn execute_graphql_http_500() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
server
.mock("POST", "/graphql")
.with_status(500)
.with_body("Internal Server Error")
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(500).body("Internal Server Error");
})
.await;
let client = reqwest::Client::new();
@ -655,14 +657,15 @@ mod tests {
#[tokio::test]
async fn execute_graphql_errors_array() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": null, "errors": [{"message": "Variable not found"}]}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(200)
.body(r#"{"data": null, "errors": [{"message": "Variable not found"}]}"#);
})
.await;
let client = reqwest::Client::new();
@ -675,16 +678,17 @@ mod tests {
#[tokio::test]
async fn execute_graphql_correct_headers() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
let mock = server
.mock("POST", "/graphql")
.match_header("Authorization", "lin_api_test123")
.match_header("Content-Type", "application/json")
.with_status(200)
.with_body(r#"{"data": {}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.header("Authorization", "lin_api_test123")
.header("Content-Type", "application/json");
then.status(200).body(r#"{"data": {}}"#);
})
.await;
let client = reqwest::Client::new();
@ -706,14 +710,15 @@ mod tests {
#[tokio::test]
async fn fetch_viewer_id_success() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"viewer": {"id": "user-abc"}}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(200)
.body(r#"{"data": {"viewer": {"id": "user-abc"}}}"#);
})
.await;
let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string());
@ -723,14 +728,14 @@ mod tests {
#[tokio::test]
async fn fetch_viewer_id_error() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
server
.mock("POST", "/graphql")
.with_status(401)
.with_body("Unauthorized")
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(401).body("Unauthorized");
})
.await;
let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string());
@ -744,14 +749,15 @@ mod tests {
#[tokio::test]
async fn create_comment_success() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"commentCreate": {"success": true}}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(200)
.body(r#"{"data": {"commentCreate": {"success": true}}}"#);
})
.await;
let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string());
@ -761,14 +767,15 @@ mod tests {
#[tokio::test]
async fn create_comment_returns_false() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"commentCreate": {"success": false}}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(200)
.body(r#"{"data": {"commentCreate": {"success": false}}}"#);
})
.await;
let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string());
@ -783,25 +790,25 @@ mod tests {
#[tokio::test]
async fn update_issue_state_success() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
// First call: resolve state name to ID
let resolve_mock = server
.mock("POST", "/graphql")
.with_status(200)
.with_body(
r#"{"data": {"issue": {"team": {"states": {"nodes": [{"id": "state-done"}]}}}}}"#,
)
.create_async()
.await;
let resolve_mock = server.mock_async(|when, then| {
when.method("POST").path("/graphql").body_includes("team");
then.status(200)
.body(r#"{"data": {"issue": {"team": {"states": {"nodes": [{"id": "state-done"}]}}}}}"#);
}).await;
// Second call: update issue
let update_mock = server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"issueUpdate": {"success": true}}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes("issueUpdate");
then.status(200)
.body(r#"{"data": {"issueUpdate": {"success": true}}}"#);
})
.await;
let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string());
@ -814,14 +821,15 @@ mod tests {
#[tokio::test]
async fn update_issue_state_not_found() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(r#"{"data": {"issue": {"team": {"states": {"nodes": []}}}}}"#)
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(200)
.body(r#"{"data": {"issue": {"team": {"states": {"nodes": []}}}}}"#);
})
.await;
let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string());
@ -840,8 +848,8 @@ mod tests {
#[tokio::test]
async fn fetch_candidate_issues_single_page() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
let issue = complete_issue_json();
let body = serde_json::json!({
@ -854,10 +862,10 @@ mod tests {
});
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(body.to_string())
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(200).body(body.to_string());
})
.await;
let tracker = LinearTracker::new(config, reqwest::Client::new(), "my-project".to_string());
@ -873,8 +881,8 @@ mod tests {
#[tokio::test]
async fn fetch_candidate_issues_two_pages() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
let issue1 = serde_json::json!({
"id": "id-1", "identifier": "T-1", "title": "First",
@ -905,22 +913,20 @@ mod tests {
});
server
.mock("POST", "/graphql")
.match_body(mockito::Matcher::PartialJsonString(
r#"{"variables":{"cursor":null}}"#.to_string(),
))
.with_status(200)
.with_body(page1.to_string())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes(r#""cursor":null"#);
then.status(200).body(page1.to_string());
})
.await;
server
.mock("POST", "/graphql")
.match_body(mockito::Matcher::PartialJsonString(
r#"{"variables":{"cursor":"cursor-1"}}"#.to_string(),
))
.with_status(200)
.with_body(page2.to_string())
.create_async()
.mock_async(|when, then| {
when.method("POST")
.path("/graphql")
.body_includes(r#""cursor":"cursor-1""#);
then.status(200).body(page2.to_string());
})
.await;
let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string());
@ -933,8 +939,8 @@ mod tests {
#[tokio::test]
async fn fetch_candidate_issues_empty() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
let body = serde_json::json!({
"data": {
@ -946,10 +952,10 @@ mod tests {
});
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(body.to_string())
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(200).body(body.to_string());
})
.await;
let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string());
@ -964,8 +970,8 @@ mod tests {
#[tokio::test]
async fn fetch_issues_by_ids_ordering() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
// API returns in different order than requested
let body = serde_json::json!({
@ -988,10 +994,10 @@ mod tests {
});
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(body.to_string())
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql");
then.status(200).body(body.to_string());
})
.await;
let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string());
@ -1007,29 +1013,13 @@ mod tests {
#[tokio::test]
async fn fetch_issues_by_ids_batching() {
let mut server = mockito::Server::new_async().await;
let config = mock_config(&server.url());
let server = httpmock::MockServer::start_async().await;
let config = mock_config(&server.url(""));
// Create 51 IDs to trigger 2 batches
let ids: Vec<String> = (0..51).map(|i| format!("id-{i}")).collect();
let id_refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
// Second batch (ids 50..51) — registered first for LIFO
let batch2_node = serde_json::json!({
"id": "id-50", "identifier": "T-50", "title": "T50",
"state": { "name": "Todo" }, "url": "https://linear.app/t/50",
"labels": { "nodes": [] }, "inverseRelations": { "nodes": [] }
});
let batch2 = serde_json::json!({
"data": { "issues": { "nodes": [batch2_node] } }
});
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(batch2.to_string())
.create_async()
.await;
// First batch (ids 0..50)
let batch1_nodes: Vec<Value> = (0..50)
.map(|i| {
@ -1048,10 +1038,26 @@ mod tests {
"data": { "issues": { "nodes": batch1_nodes } }
});
server
.mock("POST", "/graphql")
.with_status(200)
.with_body(batch1.to_string())
.create_async()
.mock_async(|when, then| {
when.method("POST").path("/graphql").body_includes("id-49");
then.status(200).body(batch1.to_string());
})
.await;
// Second batch (id 50)
let batch2_node = serde_json::json!({
"id": "id-50", "identifier": "T-50", "title": "T50",
"state": { "name": "Todo" }, "url": "https://linear.app/t/50",
"labels": { "nodes": [] }, "inverseRelations": { "nodes": [] }
});
let batch2 = serde_json::json!({
"data": { "issues": { "nodes": [batch2_node] } }
});
server
.mock_async(|when, then| {
when.method("POST").path("/graphql").body_includes("id-50");
then.status(200).body(batch2.to_string());
})
.await;
let tracker = LinearTracker::new(config, reqwest::Client::new(), "proj".to_string());

View file

@ -65,7 +65,6 @@ reqwest.workspace = true
base64.workspace = true
toml.workspace = true
fabro-mcp = { path = "../fabro-mcp" }
mockito = "1"
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3"
dotenvy.workspace = true