use super::*; use crate::core::error::LoreError; use wiremock::matchers::{body_json, header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; // ═══════════════════════════════════════════════════════════════════════ // AC-1: GraphQL Client // ═══════════════════════════════════════════════════════════════════════ #[tokio::test] async fn test_graphql_query_success() { let server = MockServer::start().await; let response_body = serde_json::json!({ "data": { "project": { "id": "1" } } }); Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "test-token"); let result = client .query("{ project { id } }", serde_json::json!({})) .await .unwrap(); assert_eq!(result.data["project"]["id"], "1"); assert!(!result.had_partial_errors); assert!(result.first_partial_error.is_none()); } #[tokio::test] async fn test_graphql_query_with_errors_no_data() { let server = MockServer::start().await; let response_body = serde_json::json!({ "errors": [{ "message": "Field 'foo' not found" }] }); Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "test-token"); let err = client .query("{ foo }", serde_json::json!({})) .await .unwrap_err(); match err { LoreError::Other(msg) => { assert!(msg.contains("Field 'foo' not found"), "got: {msg}"); } other => panic!("Expected LoreError::Other, got: {other:?}"), } } #[tokio::test] async fn test_graphql_auth_uses_bearer() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .and(header("Authorization", "Bearer my-secret-token")) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})), ) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "my-secret-token"); let result = client.query("{ ok }", serde_json::json!({})).await; assert!(result.is_ok()); } #[tokio::test] async fn test_graphql_401_maps_to_auth_failed() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(401)) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "bad-token"); let err = client .query("{ me }", serde_json::json!({})) .await .unwrap_err(); assert!(matches!(err, LoreError::GitLabAuthFailed)); } #[tokio::test] async fn test_graphql_403_maps_to_auth_failed() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(403)) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "forbidden-token"); let err = client .query("{ admin }", serde_json::json!({})) .await .unwrap_err(); assert!(matches!(err, LoreError::GitLabAuthFailed)); } #[tokio::test] async fn test_graphql_404_maps_to_not_found() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "token"); let err = client .query("{ x }", serde_json::json!({})) .await .unwrap_err(); match err { LoreError::GitLabNotFound { resource } => { assert_eq!(resource, "GraphQL endpoint"); } other => panic!("Expected GitLabNotFound, got: {other:?}"), } } #[tokio::test] async fn test_graphql_partial_data_with_errors_returns_data() { let server = MockServer::start().await; let response_body = serde_json::json!({ "data": { "project": { "name": "test" } }, "errors": [{ "message": "Some field failed" }] }); Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "token"); let result = client .query("{ project { name } }", serde_json::json!({})) .await .unwrap(); assert_eq!(result.data["project"]["name"], "test"); assert!(result.had_partial_errors); assert_eq!( result.first_partial_error.as_deref(), Some("Some field failed") ); } #[tokio::test] async fn test_retry_after_delta_seconds() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "120")) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "token"); let err = client .query("{ x }", serde_json::json!({})) .await .unwrap_err(); match err { LoreError::GitLabRateLimited { retry_after } => { assert_eq!(retry_after, 120); } other => panic!("Expected GitLabRateLimited, got: {other:?}"), } } #[tokio::test] async fn test_retry_after_http_date_format() { let server = MockServer::start().await; let future = SystemTime::now() + Duration::from_secs(90); let date_str = httpdate::fmt_http_date(future); Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", date_str)) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "token"); let err = client .query("{ x }", serde_json::json!({})) .await .unwrap_err(); match err { LoreError::GitLabRateLimited { retry_after } => { assert!( (85..=95).contains(&retry_after), "retry_after={retry_after}" ); } other => panic!("Expected GitLabRateLimited, got: {other:?}"), } } #[tokio::test] async fn test_retry_after_invalid_falls_back_to_60() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "garbage")) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "token"); let err = client .query("{ x }", serde_json::json!({})) .await .unwrap_err(); match err { LoreError::GitLabRateLimited { retry_after } => { assert_eq!(retry_after, 60); } other => panic!("Expected GitLabRateLimited, got: {other:?}"), } } #[test] fn test_retry_after_http_date_in_past_returns_one_second() { let now = SystemTime::now(); let past = now - Duration::from_secs(120); let date_str = httpdate::fmt_http_date(past); assert_eq!(parse_retry_after_value(&date_str, now), 1); } #[test] fn test_retry_after_delta_seconds_trims_whitespace() { let now = SystemTime::now(); assert_eq!(parse_retry_after_value(" 120 ", now), 120); } #[tokio::test] async fn test_graphql_network_error() { let client = GraphqlClient::new("http://127.0.0.1:1", "token"); let err = client .query("{ x }", serde_json::json!({})) .await .unwrap_err(); assert!( matches!(err, LoreError::GitLabNetworkError { .. }), "Expected GitLabNetworkError, got: {err:?}" ); } #[tokio::test] async fn test_graphql_request_body_format() { let server = MockServer::start().await; let expected_body = serde_json::json!({ "query": "{ project(fullPath: $path) { id } }", "variables": { "path": "group/repo" } }); Mock::given(method("POST")) .and(path("/api/graphql")) .and(body_json(&expected_body)) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!({"data": {"project": {"id": "1"}}})), ) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "token"); let result = client .query( "{ project(fullPath: $path) { id } }", serde_json::json!({"path": "group/repo"}), ) .await; assert!(result.is_ok(), "Body format mismatch: {result:?}"); } #[tokio::test] async fn test_graphql_base_url_trailing_slash() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})), ) .mount(&server) .await; let url_with_slash = format!("{}/", server.uri()); let client = GraphqlClient::new(&url_with_slash, "token"); let result = client.query("{ ok }", serde_json::json!({})).await; assert!(result.is_ok()); } #[tokio::test] async fn test_graphql_data_null_no_errors() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": null}))) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "token"); let err = client .query("{ x }", serde_json::json!({})) .await .unwrap_err(); match err { LoreError::Other(msg) => { assert!(msg.contains("missing 'data' field"), "got: {msg}"); } other => panic!("Expected LoreError::Other, got: {other:?}"), } } // ═══════════════════════════════════════════════════════════════════════ // AC-3: Status Fetcher // ═══════════════════════════════════════════════════════════════════════ /// Helper: build a GraphQL work-items response page with given issues. fn make_work_items_page( items: &[(i64, Option<&str>)], has_next_page: bool, end_cursor: Option<&str>, ) -> serde_json::Value { let nodes: Vec = items .iter() .map(|(iid, status_name)| { let mut widgets = vec![serde_json::json!({"__typename": "WorkItemWidgetDescription"})]; if let Some(name) = status_name { widgets.push(serde_json::json!({ "__typename": "WorkItemWidgetStatus", "status": { "name": name, "category": "IN_PROGRESS", "color": "#1f75cb", "iconName": "status-in-progress" } })); } serde_json::json!({ "iid": iid.to_string(), "widgets": widgets, }) }) .collect(); serde_json::json!({ "data": { "project": { "workItems": { "nodes": nodes, "pageInfo": { "endCursor": end_cursor, "hasNextPage": has_next_page, } } } } }) } /// Helper: build a page where issue has status widget but status is null. fn make_null_status_widget_page(iid: i64) -> serde_json::Value { serde_json::json!({ "data": { "project": { "workItems": { "nodes": [{ "iid": iid.to_string(), "widgets": [ {"__typename": "WorkItemWidgetStatus", "status": null} ] }], "pageInfo": { "endCursor": null, "hasNextPage": false, } } } } }) } #[tokio::test] async fn test_fetch_statuses_pagination() { let server = MockServer::start().await; // Page 1: returns cursor "cursor_page2" Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with({ ResponseTemplate::new(200).set_body_json(make_work_items_page( &[(1, Some("In progress")), (2, Some("To do"))], true, Some("cursor_page2"), )) }) .up_to_n_times(1) .expect(1) .mount(&server) .await; // Page 2: no more pages Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with( ResponseTemplate::new(200).set_body_json(make_work_items_page( &[(3, Some("Done"))], false, None, )), ) .expect(1) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert_eq!(result.statuses.len(), 3); assert!(result.statuses.contains_key(&1)); assert!(result.statuses.contains_key(&2)); assert!(result.statuses.contains_key(&3)); assert_eq!(result.all_fetched_iids.len(), 3); assert!(result.unsupported_reason.is_none()); } #[tokio::test] async fn test_fetch_statuses_no_status_widget() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": { "project": { "workItems": { "nodes": [{ "iid": "42", "widgets": [ {"__typename": "WorkItemWidgetDescription"}, {"__typename": "WorkItemWidgetLabels"} ] }], "pageInfo": {"endCursor": null, "hasNextPage": false} } } } }))) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert!( result.statuses.is_empty(), "No status widget -> no statuses" ); assert!( result.all_fetched_iids.contains(&42), "IID 42 should still be in all_fetched_iids" ); } #[tokio::test] async fn test_fetch_statuses_404_graceful() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert!(result.statuses.is_empty()); assert!(result.all_fetched_iids.is_empty()); assert!(matches!( result.unsupported_reason, Some(UnsupportedReason::GraphqlEndpointMissing) )); } #[tokio::test] async fn test_fetch_statuses_403_graceful() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(403)) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert!(result.statuses.is_empty()); assert!(result.all_fetched_iids.is_empty()); assert!(matches!( result.unsupported_reason, Some(UnsupportedReason::AuthForbidden) )); } #[tokio::test] async fn test_fetch_statuses_unsupported_reason_none_on_success() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with( ResponseTemplate::new(200).set_body_json(make_work_items_page( &[(1, Some("To do"))], false, None, )), ) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert!(result.unsupported_reason.is_none()); } #[tokio::test] async fn test_typename_matching_ignores_non_status_widgets() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": { "project": { "workItems": { "nodes": [{ "iid": "10", "widgets": [ {"__typename": "WorkItemWidgetDescription"}, {"__typename": "WorkItemWidgetLabels"}, {"__typename": "WorkItemWidgetAssignees"}, { "__typename": "WorkItemWidgetStatus", "status": { "name": "In progress", "category": "IN_PROGRESS" } } ] }], "pageInfo": {"endCursor": null, "hasNextPage": false} } } } }))) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert_eq!(result.statuses.len(), 1); assert_eq!(result.statuses[&10].name, "In progress"); } #[tokio::test] async fn test_fetch_statuses_cursor_stall_aborts() { let server = MockServer::start().await; let stall_response = serde_json::json!({ "data": { "project": { "workItems": { "nodes": [{"iid": "1", "widgets": []}], "pageInfo": {"endCursor": "same_cursor", "hasNextPage": true} } } } }); Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(stall_response)) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert!( result.all_fetched_iids.contains(&1), "Should contain the one IID fetched before stall" ); } #[tokio::test] async fn test_fetch_statuses_complexity_error_reduces_page_size() { let server = MockServer::start().await; let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let call_count_clone = call_count.clone(); Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(move |_req: &wiremock::Request| { let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); if n == 0 { ResponseTemplate::new(200).set_body_json(serde_json::json!({ "errors": [{"message": "Query has complexity of 300, which exceeds max complexity of 250"}] })) } else { ResponseTemplate::new(200).set_body_json(make_work_items_page( &[(1, Some("In progress"))], false, None, )) } }) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert_eq!(result.statuses.len(), 1); assert_eq!(result.statuses[&1].name, "In progress"); assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 2); } #[tokio::test] async fn test_fetch_statuses_timeout_error_reduces_page_size() { let server = MockServer::start().await; let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let call_count_clone = call_count.clone(); Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(move |_req: &wiremock::Request| { let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); if n == 0 { ResponseTemplate::new(200).set_body_json(serde_json::json!({ "errors": [{"message": "Query timeout after 30000ms"}] })) } else { ResponseTemplate::new(200).set_body_json(make_work_items_page( &[(5, Some("Done"))], false, None, )) } }) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert_eq!(result.statuses.len(), 1); assert!(call_count.load(std::sync::atomic::Ordering::SeqCst) >= 2); } #[tokio::test] async fn test_fetch_statuses_smallest_page_still_fails() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "errors": [{"message": "Query has complexity of 9999"}] }))) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let err = fetch_issue_statuses(&client, "group/project") .await .unwrap_err(); assert!( matches!(err, LoreError::Other(_)), "Expected error after exhausting all page sizes, got: {err:?}" ); } #[tokio::test] async fn test_fetch_statuses_page_size_resets_after_success() { let server = MockServer::start().await; let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let call_count_clone = call_count.clone(); Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(move |_req: &wiremock::Request| { let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); match n { 0 => { // Page 1 at size 100: success, has next page ResponseTemplate::new(200).set_body_json(make_work_items_page( &[(1, Some("To do"))], true, Some("cursor_p2"), )) } 1 => { // Page 2 at size 100 (reset): complexity error ResponseTemplate::new(200).set_body_json(serde_json::json!({ "errors": [{"message": "Query has complexity of 300"}] })) } 2 => { // Page 2 retry at size 50: success ResponseTemplate::new(200).set_body_json(make_work_items_page( &[(2, Some("Done"))], false, None, )) } _ => ResponseTemplate::new(500), } }) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert_eq!(result.statuses.len(), 2); assert!(result.statuses.contains_key(&1)); assert!(result.statuses.contains_key(&2)); assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3); } #[tokio::test] async fn test_fetch_statuses_partial_errors_tracked() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": { "project": { "workItems": { "nodes": [{"iid": "1", "widgets": [ {"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}} ]}], "pageInfo": {"endCursor": null, "hasNextPage": false} } } }, "errors": [{"message": "Rate limit warning: approaching limit"}] }))) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert_eq!(result.partial_error_count, 1); assert_eq!( result.first_partial_error.as_deref(), Some("Rate limit warning: approaching limit") ); assert_eq!(result.statuses.len(), 1); } #[tokio::test] async fn test_fetch_statuses_empty_project() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": { "project": { "workItems": { "nodes": [], "pageInfo": {"endCursor": null, "hasNextPage": false} } } } }))) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert!(result.statuses.is_empty()); assert!(result.all_fetched_iids.is_empty()); assert!(result.unsupported_reason.is_none()); assert_eq!(result.partial_error_count, 0); } #[tokio::test] async fn test_fetch_statuses_null_status_in_widget() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(make_null_status_widget_page(42))) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert!( result.statuses.is_empty(), "Null status should not be in map" ); assert!( result.all_fetched_iids.contains(&42), "IID should still be tracked in all_fetched_iids" ); } #[tokio::test] async fn test_fetch_statuses_non_numeric_iid_skipped() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": { "project": { "workItems": { "nodes": [ { "iid": "not_a_number", "widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}}] }, { "iid": "7", "widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "Done"}}] } ], "pageInfo": {"endCursor": null, "hasNextPage": false} } } } })), ) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert_eq!(result.statuses.len(), 1); assert!(result.statuses.contains_key(&7)); assert_eq!(result.all_fetched_iids.len(), 1); } #[tokio::test] async fn test_fetch_statuses_null_cursor_with_has_next_aborts() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/graphql")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": { "project": { "workItems": { "nodes": [{"iid": "1", "widgets": []}], "pageInfo": {"endCursor": null, "hasNextPage": true} } } } }))) .mount(&server) .await; let client = GraphqlClient::new(&server.uri(), "tok123"); let result = fetch_issue_statuses(&client, "group/project") .await .unwrap(); assert_eq!(result.all_fetched_iids.len(), 1); }