Loading crates/integration-testkit/src/visitor/enforcement.rs +66 −15 Original line number Diff line number Diff line Loading @@ -30,6 +30,11 @@ pub enum Requirement { AggregationSort, /// Query has `range` — test must call `assert_node_count`. Range, /// Query returns nodes — test must call `assert_node_count`. /// /// Always derived for `search`, `traversal`, and `neighbors` queries /// to ensure no unexpected rows leak into the response. NodeCount, } impl std::fmt::Display for Requirement { Loading @@ -53,6 +58,9 @@ impl std::fmt::Display for Requirement { ) } Self::Range => write!(f, "Range (query has range — call assert_node_count)"), Self::NodeCount => { write!(f, "NodeCount (call assert_node_count to verify total rows)") } } } } Loading Loading @@ -98,8 +106,11 @@ impl QueryRequirements for Input { } QueryType::Neighbors => { reqs.insert(Requirement::Neighbors); reqs.insert(Requirement::NodeCount); } QueryType::Traversal | QueryType::Search => { reqs.insert(Requirement::NodeCount); } QueryType::Traversal | QueryType::Search => {} } // Traversal queries with joins produce edges the test must verify per type. Loading Loading @@ -210,7 +221,8 @@ mod tests { ); let reqs = input.requirements(); assert!(reqs.contains(&Requirement::OrderBy)); assert_eq!(reqs.len(), 1); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 2); } #[test] Loading @@ -224,7 +236,8 @@ mod tests { assert!(reqs.contains(&Requirement::Filter { field: "state".into() })); assert_eq!(reqs.len(), 1); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 2); } #[test] Loading @@ -242,7 +255,8 @@ mod tests { assert!(reqs.contains(&Requirement::Filter { field: "user_type".into() })); assert_eq!(reqs.len(), 2); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 3); } #[test] Loading @@ -254,7 +268,8 @@ mod tests { ); let reqs = input.requirements(); assert!(reqs.contains(&Requirement::NodeIds)); assert_eq!(reqs.len(), 1); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 2); } #[test] Loading Loading @@ -290,18 +305,23 @@ mod tests { } #[test] fn requirements_from_plain_search_is_empty() { fn requirements_from_plain_search_has_node_count() { let input = parse_test_input( r#"{"query_type": "search", "node": {"id": "u", "entity": "User"}, "limit": 10}"#, ); assert!(input.requirements().is_empty()); let reqs = input.requirements(); assert_eq!(reqs, HashSet::from([Requirement::NodeCount])); } #[test] fn requirements_from_default_input_is_empty() { assert!(Input::default().requirements().is_empty()); fn requirements_from_default_input_has_node_count() { // Input::default() has query_type Search, which always requires NodeCount. assert_eq!( Input::default().requirements(), HashSet::from([Requirement::NodeCount]) ); } #[test] Loading @@ -319,7 +339,8 @@ mod tests { assert!(reqs.contains(&Requirement::Relationship { edge_type: "MEMBER_OF".into() })); assert_eq!(reqs.len(), 1); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 2); } #[test] Loading @@ -344,7 +365,8 @@ mod tests { assert!(reqs.contains(&Requirement::Relationship { edge_type: "CONTAINS".into() })); assert_eq!(reqs.len(), 2); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 3); } #[test] Loading Loading @@ -379,7 +401,8 @@ mod tests { let reqs = input.requirements(); assert!(reqs.contains(&Requirement::Neighbors)); assert!(reqs.contains(&Requirement::NodeIds)); assert_eq!(reqs.len(), 2); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 3); } #[test] Loading Loading @@ -407,18 +430,19 @@ mod tests { ); let reqs = input.requirements(); assert!(reqs.contains(&Requirement::Range)); assert_eq!(reqs.len(), 1); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 2); } // ── Assertion enforcement ──────────────────────────────────────── #[test] fn for_query_with_no_requirements_drops_cleanly() { fn for_query_plain_search_requires_node_count() { let input = parse_test_input( r#"{"query_type": "search", "node": {"id": "u", "entity": "User"}, "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); drop(view); view.assert_node_count(2); } #[test] Loading @@ -428,6 +452,7 @@ mod tests { "order_by": {"node": "u", "property": "id"}, "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); view.assert_node_order("User", &[1, 2]); } Loading @@ -439,6 +464,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); view.assert_filter("User", "username", |n| n.prop_str("username").is_some()); } Loading @@ -451,6 +477,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); view.assert_filter("User", "username", |n| n.prop_str("username").is_some()); view.assert_filter("User", "state", |n| { matches!(n.prop_str("username"), Some("alice" | "bob")) Loading Loading @@ -479,6 +506,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); let _ = view.node_ids("User").into_inner(); } Loading Loading @@ -523,6 +551,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_response()); view.assert_node_count(4); let _ = view.edges_of_type("MEMBER_OF").into_inner(); } Loading @@ -538,6 +567,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_response()); view.assert_node_count(4); view.assert_edge_exists("User", 1, "Group", 100, "MEMBER_OF"); } Loading Loading @@ -573,6 +603,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_response()); view.assert_node_count(4); let _ = view.edges_of_type("MEMBER_OF").into_inner(); let _ = view.edges_of_type("CONTAINS").into_inner(); } Loading Loading @@ -606,6 +637,7 @@ mod tests { "neighbors": {"node": "u", "direction": "outgoing"}}"#, ); let view = ResponseView::for_query(&input, sample_neighbors_response()); view.assert_node_count(3); view.assert_edge_exists("User", 1, "Group", 100, "MEMBER_OF"); let _ = view.node_ids("User").into_inner(); } Loading @@ -618,6 +650,7 @@ mod tests { "neighbors": {"node": "u", "direction": "outgoing"}}"#, ); let view = ResponseView::for_query(&input, sample_neighbors_response()); view.assert_node_count(3); let _ = view.edges_of_type("MEMBER_OF").into_inner(); let _ = view.node_ids("User").into_inner(); } Loading @@ -632,6 +665,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_aggregation_response()); // aggregation queries don't require NodeCount view.assert_node_order("User", &[1, 2]); view.assert_node("User", 1, |n| n.prop_str("username") == Some("alice")); } Loading @@ -656,6 +690,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); view.assert_node_ids("User", &[1, 2]); } Loading @@ -667,6 +702,7 @@ mod tests { "neighbors": {"node": "u", "direction": "outgoing"}}"#, ); let view = ResponseView::for_query(&input, sample_neighbors_response()); view.assert_node_count(3); view.assert_edge_set("MEMBER_OF", &[(1, 100), (1, 101)]); let _ = view.node_ids("User").into_inner(); } Loading @@ -679,6 +715,7 @@ mod tests { "neighbors": {"node": "u", "direction": "outgoing"}}"#, ); let view = ResponseView::for_query(&input, sample_neighbors_response()); view.assert_node_count(3); view.assert_edge_count("MEMBER_OF", 2); let _ = view.node_ids("User").into_inner(); } Loading @@ -698,6 +735,18 @@ mod tests { // ── Panic on unsatisfied ───────────────────────────────────────── #[test] #[should_panic(expected = "NodeCount")] fn for_query_panics_on_unsatisfied_node_count() { let input = parse_test_input( r#"{"query_type": "search", "node": {"id": "u", "entity": "User"}, "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); drop(view); } #[test] #[should_panic(expected = "unsatisfied assertion requirements")] fn for_query_panics_on_unsatisfied_order() { Loading Loading @@ -847,6 +896,7 @@ mod tests { "order_by": {"node": "u", "property": "id"}, "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); view.skip_requirement(Requirement::OrderBy); } Loading @@ -862,6 +912,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_response()); view.assert_node_count(4); view.skip_requirement(Requirement::Relationship { edge_type: "MEMBER_OF".into(), }); Loading crates/integration-testkit/src/visitor/mod.rs +3 −1 Original line number Diff line number Diff line Loading @@ -181,12 +181,14 @@ impl ResponseView { self.response.nodes.len() } /// Assert exact node count. Satisfies [`Requirement::Range`]. /// Assert exact node count. Satisfies [`Requirement::Range`] and /// [`Requirement::NodeCount`]. /// /// Does NOT satisfy [`Requirement::NodeIds`] — use [`node_ids`](Self::node_ids) /// or [`assert_node_order`](Self::assert_node_order) to verify which IDs were returned. pub fn assert_node_count(&self, expected: usize) { self.tracker.satisfy(Requirement::Range); self.tracker.satisfy(Requirement::NodeCount); assert_eq!( self.response.nodes.len(), expected, Loading crates/integration-tests/tests/server/data_correctness.rs +23 −0 Original line number Diff line number Diff line Loading @@ -349,6 +349,7 @@ async fn search_filter_in_returns_matching_rows(ctx: &TestContext) { ) .await; resp.assert_node_count(3); resp.assert_node_ids("Project", &[1000, 1002, 1004]); resp.assert_filter("Project", "visibility_level", |n| { Loading Loading @@ -391,6 +392,7 @@ async fn search_node_ids_returns_only_specified(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("Group", &[100, 102]); resp.find_node("Group", 100) .unwrap() Loading @@ -414,6 +416,7 @@ async fn search_filter_contains_returns_substring_matches(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("User", &[1, 3]); resp.assert_filter("User", "username", |n| { n.prop_str("username").is_some_and(|u| u.contains("li")) Loading Loading @@ -489,6 +492,7 @@ async fn search_redaction_returns_only_allowed_ids(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("User", &[1, 3]); resp.assert_node_absent("User", 2); resp.assert_node_absent("User", 5); Loading @@ -507,6 +511,7 @@ async fn search_unicode_properties_survive_pipeline(ctx: &TestContext) { ) .await; resp.assert_node_count(1); resp.assert_node_ids("User", &[6]); resp.assert_node("User", 6, |n| { n.prop_str("username") == Some("用户_émoji_🎉") Loading Loading @@ -586,6 +591,7 @@ async fn search_combined_filter_node_ids_order_by(ctx: &TestContext) { ) .await; resp.assert_node_count(3); resp.assert_node_order("User", &[3, 2, 1]); resp.assert_filter("User", "state", |n| n.prop_str("state") == Some("active")); resp.assert_node_absent("User", 5); Loading @@ -611,6 +617,7 @@ async fn traversal_user_group_returns_correct_pairs_and_edges(ctx: &TestContext) ) .await; resp.assert_node_count(9); resp.assert_edge_set( "MEMBER_OF", &[ Loading Loading @@ -655,6 +662,7 @@ async fn traversal_three_hop_returns_all_user_group_project_paths(ctx: &TestCont ) .await; resp.assert_node_count(14); resp.assert_referential_integrity(); let member_of: HashSet<(i64, i64)> = resp Loading Loading @@ -698,6 +706,7 @@ async fn traversal_user_authored_mr_returns_correct_edges(ctx: &TestContext) { ) .await; resp.assert_node_count(7); resp.assert_referential_integrity(); resp.assert_edge_exists("User", 1, "MergeRequest", 2000, "AUTHORED"); Loading Loading @@ -733,6 +742,7 @@ async fn traversal_redaction_removes_unauthorized_data(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("User", &[1]); resp.assert_node_ids("Group", &[100]); resp.assert_node_absent("User", 2); Loading @@ -758,6 +768,7 @@ async fn traversal_with_order_by(ctx: &TestContext) { ) .await; resp.assert_node_count(9); resp.assert_node_order("User", &[6, 5, 4, 3, 2, 1]); let _ = resp.edges_of_type("MEMBER_OF").into_inner(); } Loading @@ -778,6 +789,7 @@ async fn traversal_variable_length_reaches_depth_2(ctx: &TestContext) { ) .await; resp.assert_node_count(3); resp.assert_node_ids("Group", &[100, 200, 300]); resp.assert_edge_exists("Group", 100, "Group", 200, "CONTAINS"); resp.assert_edge_exists("Group", 100, "Group", 300, "CONTAINS"); Loading @@ -799,6 +811,7 @@ async fn traversal_incoming_direction(ctx: &TestContext) { ) .await; resp.assert_node_count(4); resp.assert_node_ids("User", &[1, 2, 6]); resp.assert_edge_exists("User", 1, "Group", 100, "MEMBER_OF"); resp.assert_edge_exists("User", 2, "Group", 100, "MEMBER_OF"); Loading @@ -822,6 +835,7 @@ async fn traversal_with_filter_narrows_results(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("User", &[5]); resp.assert_filter("User", "state", |n| n.prop_str("state") == Some("blocked")); resp.assert_edge_exists("User", 5, "Group", 101, "MEMBER_OF"); Loading Loading @@ -1156,6 +1170,7 @@ async fn neighbors_outgoing_returns_correct_targets(ctx: &TestContext) { ) .await; resp.assert_node_count(6); resp.assert_referential_integrity(); resp.assert_node_ids("User", &[1]); Loading Loading @@ -1186,6 +1201,7 @@ async fn neighbors_incoming_returns_correct_sources(ctx: &TestContext) { ) .await; resp.assert_node_count(4); resp.assert_node_ids("Group", &[100]); resp.assert_node_ids("User", &[1, 2, 6]); Loading @@ -1206,6 +1222,7 @@ async fn neighbors_rel_types_filter_works(ctx: &TestContext) { ) .await; resp.assert_node_count(4); resp.assert_node_ids("Group", &[100, 200]); resp.assert_node_ids("Project", &[1000, 1002]); resp.assert_edge_count("CONTAINS", 3); Loading @@ -1223,6 +1240,7 @@ async fn neighbors_both_direction_returns_all_connected(ctx: &TestContext) { ) .await; resp.assert_node_count(7); resp.assert_node_ids("Group", &[100, 200]); resp.assert_node_ids("User", &[1, 2, 6]); resp.assert_node_ids("Project", &[1000, 1002]); Loading @@ -1244,6 +1262,7 @@ async fn neighbors_mixed_entity_types(ctx: &TestContext) { ) .await; resp.assert_node_count(5); resp.assert_referential_integrity(); resp.assert_node_ids("User", &[1]); resp.assert_node_ids("Note", &[3000, 3002, 3003]); Loading @@ -1270,6 +1289,7 @@ async fn neighbors_redaction_removes_unauthorized_targets(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("Group", &[100]); resp.assert_edge_exists("User", 1, "Group", 100, "MEMBER_OF"); resp.assert_edge_absent("User", 1, "Group", 102, "MEMBER_OF"); Loading Loading @@ -1299,6 +1319,7 @@ async fn traversal_referential_integrity_on_complex_query(ctx: &TestContext) { ) .await; resp.assert_node_count(14); resp.assert_referential_integrity(); let member_of = resp.edges_of_type("MEMBER_OF"); Loading @@ -1323,6 +1344,7 @@ async fn giant_string_survives_pipeline(ctx: &TestContext) { ) .await; resp.assert_node_count(1); resp.assert_node_ids("Note", &[3002]); resp.assert_node("Note", 3002, |n| { n.prop_str("note") Loading @@ -1342,6 +1364,7 @@ async fn sql_injection_string_preserved(ctx: &TestContext) { ) .await; resp.assert_node_count(1); resp.assert_node_ids("Note", &[3003]); resp.assert_node("Note", 3003, |n| { n.prop_str("note").is_some_and(|s| s.contains("DROP TABLE")) Loading Loading
crates/integration-testkit/src/visitor/enforcement.rs +66 −15 Original line number Diff line number Diff line Loading @@ -30,6 +30,11 @@ pub enum Requirement { AggregationSort, /// Query has `range` — test must call `assert_node_count`. Range, /// Query returns nodes — test must call `assert_node_count`. /// /// Always derived for `search`, `traversal`, and `neighbors` queries /// to ensure no unexpected rows leak into the response. NodeCount, } impl std::fmt::Display for Requirement { Loading @@ -53,6 +58,9 @@ impl std::fmt::Display for Requirement { ) } Self::Range => write!(f, "Range (query has range — call assert_node_count)"), Self::NodeCount => { write!(f, "NodeCount (call assert_node_count to verify total rows)") } } } } Loading Loading @@ -98,8 +106,11 @@ impl QueryRequirements for Input { } QueryType::Neighbors => { reqs.insert(Requirement::Neighbors); reqs.insert(Requirement::NodeCount); } QueryType::Traversal | QueryType::Search => { reqs.insert(Requirement::NodeCount); } QueryType::Traversal | QueryType::Search => {} } // Traversal queries with joins produce edges the test must verify per type. Loading Loading @@ -210,7 +221,8 @@ mod tests { ); let reqs = input.requirements(); assert!(reqs.contains(&Requirement::OrderBy)); assert_eq!(reqs.len(), 1); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 2); } #[test] Loading @@ -224,7 +236,8 @@ mod tests { assert!(reqs.contains(&Requirement::Filter { field: "state".into() })); assert_eq!(reqs.len(), 1); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 2); } #[test] Loading @@ -242,7 +255,8 @@ mod tests { assert!(reqs.contains(&Requirement::Filter { field: "user_type".into() })); assert_eq!(reqs.len(), 2); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 3); } #[test] Loading @@ -254,7 +268,8 @@ mod tests { ); let reqs = input.requirements(); assert!(reqs.contains(&Requirement::NodeIds)); assert_eq!(reqs.len(), 1); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 2); } #[test] Loading Loading @@ -290,18 +305,23 @@ mod tests { } #[test] fn requirements_from_plain_search_is_empty() { fn requirements_from_plain_search_has_node_count() { let input = parse_test_input( r#"{"query_type": "search", "node": {"id": "u", "entity": "User"}, "limit": 10}"#, ); assert!(input.requirements().is_empty()); let reqs = input.requirements(); assert_eq!(reqs, HashSet::from([Requirement::NodeCount])); } #[test] fn requirements_from_default_input_is_empty() { assert!(Input::default().requirements().is_empty()); fn requirements_from_default_input_has_node_count() { // Input::default() has query_type Search, which always requires NodeCount. assert_eq!( Input::default().requirements(), HashSet::from([Requirement::NodeCount]) ); } #[test] Loading @@ -319,7 +339,8 @@ mod tests { assert!(reqs.contains(&Requirement::Relationship { edge_type: "MEMBER_OF".into() })); assert_eq!(reqs.len(), 1); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 2); } #[test] Loading @@ -344,7 +365,8 @@ mod tests { assert!(reqs.contains(&Requirement::Relationship { edge_type: "CONTAINS".into() })); assert_eq!(reqs.len(), 2); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 3); } #[test] Loading Loading @@ -379,7 +401,8 @@ mod tests { let reqs = input.requirements(); assert!(reqs.contains(&Requirement::Neighbors)); assert!(reqs.contains(&Requirement::NodeIds)); assert_eq!(reqs.len(), 2); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 3); } #[test] Loading Loading @@ -407,18 +430,19 @@ mod tests { ); let reqs = input.requirements(); assert!(reqs.contains(&Requirement::Range)); assert_eq!(reqs.len(), 1); assert!(reqs.contains(&Requirement::NodeCount)); assert_eq!(reqs.len(), 2); } // ── Assertion enforcement ──────────────────────────────────────── #[test] fn for_query_with_no_requirements_drops_cleanly() { fn for_query_plain_search_requires_node_count() { let input = parse_test_input( r#"{"query_type": "search", "node": {"id": "u", "entity": "User"}, "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); drop(view); view.assert_node_count(2); } #[test] Loading @@ -428,6 +452,7 @@ mod tests { "order_by": {"node": "u", "property": "id"}, "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); view.assert_node_order("User", &[1, 2]); } Loading @@ -439,6 +464,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); view.assert_filter("User", "username", |n| n.prop_str("username").is_some()); } Loading @@ -451,6 +477,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); view.assert_filter("User", "username", |n| n.prop_str("username").is_some()); view.assert_filter("User", "state", |n| { matches!(n.prop_str("username"), Some("alice" | "bob")) Loading Loading @@ -479,6 +506,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); let _ = view.node_ids("User").into_inner(); } Loading Loading @@ -523,6 +551,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_response()); view.assert_node_count(4); let _ = view.edges_of_type("MEMBER_OF").into_inner(); } Loading @@ -538,6 +567,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_response()); view.assert_node_count(4); view.assert_edge_exists("User", 1, "Group", 100, "MEMBER_OF"); } Loading Loading @@ -573,6 +603,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_response()); view.assert_node_count(4); let _ = view.edges_of_type("MEMBER_OF").into_inner(); let _ = view.edges_of_type("CONTAINS").into_inner(); } Loading Loading @@ -606,6 +637,7 @@ mod tests { "neighbors": {"node": "u", "direction": "outgoing"}}"#, ); let view = ResponseView::for_query(&input, sample_neighbors_response()); view.assert_node_count(3); view.assert_edge_exists("User", 1, "Group", 100, "MEMBER_OF"); let _ = view.node_ids("User").into_inner(); } Loading @@ -618,6 +650,7 @@ mod tests { "neighbors": {"node": "u", "direction": "outgoing"}}"#, ); let view = ResponseView::for_query(&input, sample_neighbors_response()); view.assert_node_count(3); let _ = view.edges_of_type("MEMBER_OF").into_inner(); let _ = view.node_ids("User").into_inner(); } Loading @@ -632,6 +665,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_aggregation_response()); // aggregation queries don't require NodeCount view.assert_node_order("User", &[1, 2]); view.assert_node("User", 1, |n| n.prop_str("username") == Some("alice")); } Loading @@ -656,6 +690,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); view.assert_node_ids("User", &[1, 2]); } Loading @@ -667,6 +702,7 @@ mod tests { "neighbors": {"node": "u", "direction": "outgoing"}}"#, ); let view = ResponseView::for_query(&input, sample_neighbors_response()); view.assert_node_count(3); view.assert_edge_set("MEMBER_OF", &[(1, 100), (1, 101)]); let _ = view.node_ids("User").into_inner(); } Loading @@ -679,6 +715,7 @@ mod tests { "neighbors": {"node": "u", "direction": "outgoing"}}"#, ); let view = ResponseView::for_query(&input, sample_neighbors_response()); view.assert_node_count(3); view.assert_edge_count("MEMBER_OF", 2); let _ = view.node_ids("User").into_inner(); } Loading @@ -698,6 +735,18 @@ mod tests { // ── Panic on unsatisfied ───────────────────────────────────────── #[test] #[should_panic(expected = "NodeCount")] fn for_query_panics_on_unsatisfied_node_count() { let input = parse_test_input( r#"{"query_type": "search", "node": {"id": "u", "entity": "User"}, "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); drop(view); } #[test] #[should_panic(expected = "unsatisfied assertion requirements")] fn for_query_panics_on_unsatisfied_order() { Loading Loading @@ -847,6 +896,7 @@ mod tests { "order_by": {"node": "u", "property": "id"}, "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_search_response()); view.assert_node_count(2); view.skip_requirement(Requirement::OrderBy); } Loading @@ -862,6 +912,7 @@ mod tests { "limit": 10}"#, ); let view = ResponseView::for_query(&input, sample_response()); view.assert_node_count(4); view.skip_requirement(Requirement::Relationship { edge_type: "MEMBER_OF".into(), }); Loading
crates/integration-testkit/src/visitor/mod.rs +3 −1 Original line number Diff line number Diff line Loading @@ -181,12 +181,14 @@ impl ResponseView { self.response.nodes.len() } /// Assert exact node count. Satisfies [`Requirement::Range`]. /// Assert exact node count. Satisfies [`Requirement::Range`] and /// [`Requirement::NodeCount`]. /// /// Does NOT satisfy [`Requirement::NodeIds`] — use [`node_ids`](Self::node_ids) /// or [`assert_node_order`](Self::assert_node_order) to verify which IDs were returned. pub fn assert_node_count(&self, expected: usize) { self.tracker.satisfy(Requirement::Range); self.tracker.satisfy(Requirement::NodeCount); assert_eq!( self.response.nodes.len(), expected, Loading
crates/integration-tests/tests/server/data_correctness.rs +23 −0 Original line number Diff line number Diff line Loading @@ -349,6 +349,7 @@ async fn search_filter_in_returns_matching_rows(ctx: &TestContext) { ) .await; resp.assert_node_count(3); resp.assert_node_ids("Project", &[1000, 1002, 1004]); resp.assert_filter("Project", "visibility_level", |n| { Loading Loading @@ -391,6 +392,7 @@ async fn search_node_ids_returns_only_specified(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("Group", &[100, 102]); resp.find_node("Group", 100) .unwrap() Loading @@ -414,6 +416,7 @@ async fn search_filter_contains_returns_substring_matches(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("User", &[1, 3]); resp.assert_filter("User", "username", |n| { n.prop_str("username").is_some_and(|u| u.contains("li")) Loading Loading @@ -489,6 +492,7 @@ async fn search_redaction_returns_only_allowed_ids(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("User", &[1, 3]); resp.assert_node_absent("User", 2); resp.assert_node_absent("User", 5); Loading @@ -507,6 +511,7 @@ async fn search_unicode_properties_survive_pipeline(ctx: &TestContext) { ) .await; resp.assert_node_count(1); resp.assert_node_ids("User", &[6]); resp.assert_node("User", 6, |n| { n.prop_str("username") == Some("用户_émoji_🎉") Loading Loading @@ -586,6 +591,7 @@ async fn search_combined_filter_node_ids_order_by(ctx: &TestContext) { ) .await; resp.assert_node_count(3); resp.assert_node_order("User", &[3, 2, 1]); resp.assert_filter("User", "state", |n| n.prop_str("state") == Some("active")); resp.assert_node_absent("User", 5); Loading @@ -611,6 +617,7 @@ async fn traversal_user_group_returns_correct_pairs_and_edges(ctx: &TestContext) ) .await; resp.assert_node_count(9); resp.assert_edge_set( "MEMBER_OF", &[ Loading Loading @@ -655,6 +662,7 @@ async fn traversal_three_hop_returns_all_user_group_project_paths(ctx: &TestCont ) .await; resp.assert_node_count(14); resp.assert_referential_integrity(); let member_of: HashSet<(i64, i64)> = resp Loading Loading @@ -698,6 +706,7 @@ async fn traversal_user_authored_mr_returns_correct_edges(ctx: &TestContext) { ) .await; resp.assert_node_count(7); resp.assert_referential_integrity(); resp.assert_edge_exists("User", 1, "MergeRequest", 2000, "AUTHORED"); Loading Loading @@ -733,6 +742,7 @@ async fn traversal_redaction_removes_unauthorized_data(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("User", &[1]); resp.assert_node_ids("Group", &[100]); resp.assert_node_absent("User", 2); Loading @@ -758,6 +768,7 @@ async fn traversal_with_order_by(ctx: &TestContext) { ) .await; resp.assert_node_count(9); resp.assert_node_order("User", &[6, 5, 4, 3, 2, 1]); let _ = resp.edges_of_type("MEMBER_OF").into_inner(); } Loading @@ -778,6 +789,7 @@ async fn traversal_variable_length_reaches_depth_2(ctx: &TestContext) { ) .await; resp.assert_node_count(3); resp.assert_node_ids("Group", &[100, 200, 300]); resp.assert_edge_exists("Group", 100, "Group", 200, "CONTAINS"); resp.assert_edge_exists("Group", 100, "Group", 300, "CONTAINS"); Loading @@ -799,6 +811,7 @@ async fn traversal_incoming_direction(ctx: &TestContext) { ) .await; resp.assert_node_count(4); resp.assert_node_ids("User", &[1, 2, 6]); resp.assert_edge_exists("User", 1, "Group", 100, "MEMBER_OF"); resp.assert_edge_exists("User", 2, "Group", 100, "MEMBER_OF"); Loading @@ -822,6 +835,7 @@ async fn traversal_with_filter_narrows_results(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("User", &[5]); resp.assert_filter("User", "state", |n| n.prop_str("state") == Some("blocked")); resp.assert_edge_exists("User", 5, "Group", 101, "MEMBER_OF"); Loading Loading @@ -1156,6 +1170,7 @@ async fn neighbors_outgoing_returns_correct_targets(ctx: &TestContext) { ) .await; resp.assert_node_count(6); resp.assert_referential_integrity(); resp.assert_node_ids("User", &[1]); Loading Loading @@ -1186,6 +1201,7 @@ async fn neighbors_incoming_returns_correct_sources(ctx: &TestContext) { ) .await; resp.assert_node_count(4); resp.assert_node_ids("Group", &[100]); resp.assert_node_ids("User", &[1, 2, 6]); Loading @@ -1206,6 +1222,7 @@ async fn neighbors_rel_types_filter_works(ctx: &TestContext) { ) .await; resp.assert_node_count(4); resp.assert_node_ids("Group", &[100, 200]); resp.assert_node_ids("Project", &[1000, 1002]); resp.assert_edge_count("CONTAINS", 3); Loading @@ -1223,6 +1240,7 @@ async fn neighbors_both_direction_returns_all_connected(ctx: &TestContext) { ) .await; resp.assert_node_count(7); resp.assert_node_ids("Group", &[100, 200]); resp.assert_node_ids("User", &[1, 2, 6]); resp.assert_node_ids("Project", &[1000, 1002]); Loading @@ -1244,6 +1262,7 @@ async fn neighbors_mixed_entity_types(ctx: &TestContext) { ) .await; resp.assert_node_count(5); resp.assert_referential_integrity(); resp.assert_node_ids("User", &[1]); resp.assert_node_ids("Note", &[3000, 3002, 3003]); Loading @@ -1270,6 +1289,7 @@ async fn neighbors_redaction_removes_unauthorized_targets(ctx: &TestContext) { ) .await; resp.assert_node_count(2); resp.assert_node_ids("Group", &[100]); resp.assert_edge_exists("User", 1, "Group", 100, "MEMBER_OF"); resp.assert_edge_absent("User", 1, "Group", 102, "MEMBER_OF"); Loading Loading @@ -1299,6 +1319,7 @@ async fn traversal_referential_integrity_on_complex_query(ctx: &TestContext) { ) .await; resp.assert_node_count(14); resp.assert_referential_integrity(); let member_of = resp.edges_of_type("MEMBER_OF"); Loading @@ -1323,6 +1344,7 @@ async fn giant_string_survives_pipeline(ctx: &TestContext) { ) .await; resp.assert_node_count(1); resp.assert_node_ids("Note", &[3002]); resp.assert_node("Note", 3002, |n| { n.prop_str("note") Loading @@ -1342,6 +1364,7 @@ async fn sql_injection_string_preserved(ctx: &TestContext) { ) .await; resp.assert_node_count(1); resp.assert_node_ids("Note", &[3003]); resp.assert_node("Note", 3003, |n| { n.prop_str("note").is_some_and(|s| s.contains("DROP TABLE")) Loading