Verified Commit 3eac441d authored by Michael Usachenko's avatar Michael Usachenko Committed by GitLab
Browse files

fix(data): enforce assert_node_count on all search/traversal/neighbors tests

parent 48c318df
Loading
Loading
Loading
Loading
+66 −15
Original line number Diff line number Diff line
@@ -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 {
@@ -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)")
            }
        }
    }
}
@@ -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.
@@ -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]
@@ -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]
@@ -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]
@@ -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]
@@ -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]
@@ -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]
@@ -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]
@@ -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]
@@ -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]
@@ -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]);
    }

@@ -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());
    }

@@ -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"))
@@ -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();
    }

@@ -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();
    }

@@ -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");
    }

@@ -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();
    }
@@ -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();
    }
@@ -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();
    }
@@ -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"));
    }
@@ -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]);
    }

@@ -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();
    }
@@ -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();
    }
@@ -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() {
@@ -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);
    }

@@ -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(),
        });
+3 −1
Original line number Diff line number Diff line
@@ -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,
+23 −0
Original line number Diff line number Diff line
@@ -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| {
@@ -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()
@@ -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"))
@@ -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);
@@ -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_🎉")
@@ -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);
@@ -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",
        &[
@@ -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
@@ -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");
@@ -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);
@@ -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();
}
@@ -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");
@@ -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");
@@ -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");
@@ -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]);
@@ -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]);

@@ -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);
@@ -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]);
@@ -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]);
@@ -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");
@@ -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");
@@ -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")
@@ -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"))