refactor: extract unit tests into separate _tests.rs files
Move inline #[cfg(test)] mod tests { ... } blocks from 22 source files
into dedicated _tests.rs companion files, wired via:
#[cfg(test)]
#[path = "module_tests.rs"]
mod tests;
This keeps implementation-focused source files leaner and more scannable
while preserving full access to private items through `use super::*;`.
Modules extracted:
core: db, note_parser, payloads, project, references, sync_run,
timeline_collect, timeline_expand, timeline_seed
cli: list (55 tests), who (75 tests)
documents: extractor (43 tests), regenerator
embedding: change_detector, chunking
gitlab: graphql (wiremock async tests), transformers/issue
ingestion: dirty_tracker, discussions, issues, mr_diffs
Also adds conflicts_with("explain_score") to the --detail flag in the
who command to prevent mutually exclusive flags from being combined.
All 629 unit tests pass. No behavior changes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -364,930 +364,5 @@ pub async fn fetch_issue_statuses_with_progress(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[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<serde_json::Value> = 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);
|
||||
}
|
||||
}
|
||||
#[path = "graphql_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user