cf-modkit-odata 0.7.1

ModKit OData library
Documentation
// Created: 2026-04-07 by Constructor Tech
use crate::{CursorV1, Error, ODataOrderBy, ODataQuery, OrderKey, SortDir, base64_url};

#[test]
fn test_cursor_v1_encode_decode_round_trip() {
    let cursor = CursorV1 {
        k: vec![
            "2023-11-14T12:00:00Z".to_owned(),
            "123e4567-e89b-12d3-a456-426614174000".to_owned(),
        ],
        o: SortDir::Desc,
        s: "+created_at,-id".to_owned(),
        f: Some("abc123".to_owned()),
        d: "fwd".to_owned(),
    };

    let encoded = cursor.encode().expect("encode should succeed");
    let decoded = CursorV1::decode(&encoded).expect("decode should succeed");

    assert_eq!(decoded.k, cursor.k);
    assert_eq!(decoded.o, cursor.o);
    assert_eq!(decoded.s, cursor.s);
    assert_eq!(decoded.f, cursor.f);
    assert_eq!(decoded.d, cursor.d);
}

#[test]
fn test_cursor_v1_encode_decode_without_filter_hash() {
    let cursor = CursorV1 {
        k: vec!["value1".to_owned(), "value2".to_owned()],
        o: SortDir::Asc,
        s: "+field1,+field2".to_owned(),
        f: None,
        d: "fwd".to_owned(),
    };

    let encoded = cursor.encode().expect("encode should succeed");
    let decoded = CursorV1::decode(&encoded).expect("decode should succeed");

    assert_eq!(decoded.k, cursor.k);
    assert_eq!(decoded.o, cursor.o);
    assert_eq!(decoded.s, cursor.s);
    assert_eq!(decoded.f, cursor.f);
}

#[test]
fn test_cursor_v1_decode_invalid_base64() {
    let result = CursorV1::decode("invalid_base64!");
    assert!(matches!(result, Err(Error::CursorInvalidBase64)));
}

#[test]
fn test_cursor_v1_decode_invalid_json() {
    let invalid_json = base64_url::encode(b"not_json");
    let result = CursorV1::decode(&invalid_json);
    assert!(matches!(result, Err(Error::CursorInvalidJson)));
}

#[test]
fn test_cursor_v1_decode_invalid_version() {
    let cursor_data = serde_json::json!({
        "v": 2,
        "k": ["value"],
        "o": "asc",
        "s": "+field"
    });
    let encoded = base64_url::encode(serde_json::to_vec(&cursor_data).unwrap().as_slice());
    let result = CursorV1::decode(&encoded);
    assert!(matches!(result, Err(Error::CursorInvalidVersion)));
}

#[test]
fn test_cursor_v1_decode_empty_keys() {
    let cursor_data = serde_json::json!({
        "v": 1,
        "k": [],
        "o": "asc",
        "s": "+field"
    });
    let encoded = base64_url::encode(serde_json::to_vec(&cursor_data).unwrap().as_slice());
    let result = CursorV1::decode(&encoded);
    assert!(matches!(result, Err(Error::CursorInvalidKeys)));
}

#[test]
fn test_cursor_v1_decode_empty_fields() {
    let cursor_data = serde_json::json!({
        "v": 1,
        "k": ["value"],
        "o": "asc",
        "s": ""
    });
    let encoded = base64_url::encode(serde_json::to_vec(&cursor_data).unwrap().as_slice());
    let result = CursorV1::decode(&encoded);
    assert!(matches!(result, Err(Error::CursorInvalidFields)));
}

#[test]
fn test_cursor_v1_decode_invalid_direction() {
    let cursor_data = serde_json::json!({
        "v": 1,
        "k": ["value"],
        "o": "invalid",
        "s": "+field"
    });
    let encoded = base64_url::encode(serde_json::to_vec(&cursor_data).unwrap().as_slice());
    let result = CursorV1::decode(&encoded);
    assert!(matches!(result, Err(Error::CursorInvalidDirection)));
}

#[test]
fn test_odata_order_by_to_signed_tokens() {
    let order = ODataOrderBy(vec![
        OrderKey {
            field: "created_at".to_owned(),
            dir: SortDir::Desc,
        },
        OrderKey {
            field: "id".to_owned(),
            dir: SortDir::Asc,
        },
        OrderKey {
            field: "name".to_owned(),
            dir: SortDir::Desc,
        },
    ]);

    let tokens = order.to_signed_tokens();
    assert_eq!(tokens, "-created_at,+id,-name");
}

#[test]
fn test_odata_order_by_empty_to_signed_tokens() {
    let order = ODataOrderBy::empty();
    let tokens = order.to_signed_tokens();
    assert_eq!(tokens, "");
}

#[test]
fn test_odata_order_by_equals_signed_tokens() {
    let order = ODataOrderBy(vec![
        OrderKey {
            field: "created_at".to_owned(),
            dir: SortDir::Desc,
        },
        OrderKey {
            field: "id".to_owned(),
            dir: SortDir::Asc,
        },
    ]);

    assert!(order.equals_signed_tokens("-created_at,+id"));
    assert!(order.equals_signed_tokens("  -created_at , +id  ")); // whitespace tolerance
    assert!(!order.equals_signed_tokens("-created_at,+id,+name")); // different length
    assert!(!order.equals_signed_tokens("-created_at,-id")); // different direction
    assert!(!order.equals_signed_tokens("+created_at,+id")); // different direction
}

#[test]
fn test_odata_order_by_equals_signed_tokens_implicit_asc() {
    let order = ODataOrderBy(vec![OrderKey {
        field: "name".to_owned(),
        dir: SortDir::Asc,
    }]);

    assert!(order.equals_signed_tokens("+name"));
    assert!(order.equals_signed_tokens("name")); // implicit asc
}

#[test]
fn test_odata_order_by_ensure_tiebreaker() {
    let order = ODataOrderBy(vec![OrderKey {
        field: "created_at".to_owned(),
        dir: SortDir::Desc,
    }]);

    let with_tiebreaker = order.ensure_tiebreaker("id", SortDir::Desc);
    assert_eq!(with_tiebreaker.0.len(), 2);
    assert_eq!(with_tiebreaker.0[0].field, "created_at");
    assert_eq!(with_tiebreaker.0[1].field, "id");
    assert_eq!(with_tiebreaker.0[1].dir, SortDir::Desc);
}

#[test]
fn test_odata_order_by_ensure_tiebreaker_already_present() {
    let order = ODataOrderBy(vec![
        OrderKey {
            field: "created_at".to_owned(),
            dir: SortDir::Desc,
        },
        OrderKey {
            field: "id".to_owned(),
            dir: SortDir::Asc,
        },
    ]);

    let with_tiebreaker = order.ensure_tiebreaker("id", SortDir::Desc);
    // Should not add duplicate, keep original
    assert_eq!(with_tiebreaker.0.len(), 2);
    assert_eq!(with_tiebreaker.0[1].field, "id");
    assert_eq!(with_tiebreaker.0[1].dir, SortDir::Asc); // original direction preserved
}

#[test]
fn test_odata_query_builder_pattern() {
    use crate::ast::*;

    let expr = Expr::Compare(
        Box::new(Expr::Identifier("email".to_owned())),
        CompareOperator::Eq,
        Box::new(Expr::Value(Value::String("test@example.com".to_owned()))),
    );

    let order = ODataOrderBy(vec![OrderKey {
        field: "created_at".to_owned(),
        dir: SortDir::Desc,
    }]);

    let cursor = CursorV1 {
        k: vec!["2023-11-14T12:00:00Z".to_owned()],
        o: SortDir::Desc,
        s: "-created_at".to_owned(),
        f: None,
        d: "fwd".to_owned(),
    };

    let query = ODataQuery::new()
        .with_filter(expr)
        .with_order(order)
        .with_limit(25)
        .with_cursor(cursor)
        .with_filter_hash("abc123".to_owned());

    assert!(query.filter.is_some());
    assert_eq!(query.order.0.len(), 1);
    assert_eq!(query.limit, Some(25));
    assert!(query.cursor.is_some());
    assert_eq!(query.filter_hash, Some("abc123".to_owned()));
}

#[test]
fn test_orderby_from_signed_tokens() {
    // Test basic parsing
    let result = ODataOrderBy::from_signed_tokens("+name,-created_at").unwrap();
    assert_eq!(result.0.len(), 2);
    assert_eq!(result.0[0].field, "name");
    assert_eq!(result.0[0].dir, SortDir::Asc);
    assert_eq!(result.0[1].field, "created_at");
    assert_eq!(result.0[1].dir, SortDir::Desc);

    // Test empty string should now error
    let result = ODataOrderBy::from_signed_tokens("");
    assert!(result.is_err());
    assert!(matches!(result.unwrap_err(), Error::InvalidOrderByField(_)));

    // Test single field
    let result = ODataOrderBy::from_signed_tokens("-id").unwrap();
    assert_eq!(result.0.len(), 1);
    assert_eq!(result.0[0].field, "id");
    assert_eq!(result.0[0].dir, SortDir::Desc);
}

#[test]
fn test_orderby_display_formatting() {
    // Test empty order
    let order = ODataOrderBy::empty();
    assert_eq!(format!("{order}"), "(none)");

    // Test single field
    let order = ODataOrderBy(vec![OrderKey {
        field: "name".to_owned(),
        dir: SortDir::Asc,
    }]);
    assert_eq!(format!("{order}"), "name asc");

    // Test multiple fields
    let order = ODataOrderBy(vec![
        OrderKey {
            field: "created_at".to_owned(),
            dir: SortDir::Desc,
        },
        OrderKey {
            field: "id".to_owned(),
            dir: SortDir::Desc,
        },
    ]);
    assert_eq!(format!("{order}"), "created_at desc, id desc");

    // Test mixed directions
    let order = ODataOrderBy(vec![
        OrderKey {
            field: "email".to_owned(),
            dir: SortDir::Asc,
        },
        OrderKey {
            field: "created_at".to_owned(),
            dir: SortDir::Desc,
        },
        OrderKey {
            field: "id".to_owned(),
            dir: SortDir::Desc,
        },
    ]);
    assert_eq!(format!("{order}"), "email asc, created_at desc, id desc");
}

#[test]
fn test_orderby_roundtrip_signed_tokens_display() {
    // Test that we can parse signed tokens and get readable display
    let signed = "+email,-created_at,-id";
    let order = ODataOrderBy::from_signed_tokens(signed).unwrap();
    let display = format!("{order}");
    assert_eq!(display, "email asc, created_at desc, id desc");

    // Test roundtrip back to signed tokens
    let back_to_signed = order.to_signed_tokens();
    assert_eq!(back_to_signed, signed);
}

#[test]
fn test_orderby_from_signed_tokens_error_cases() {
    // Test empty field name
    let result = ODataOrderBy::from_signed_tokens("+");
    assert!(result.is_err());
    assert!(matches!(result.unwrap_err(), Error::InvalidOrderByField(_)));

    // Test field with just sign
    let result = ODataOrderBy::from_signed_tokens("-");
    assert!(result.is_err());
    assert!(matches!(result.unwrap_err(), Error::InvalidOrderByField(_)));

    // Test field with comma but empty segment
    let result = ODataOrderBy::from_signed_tokens("+name,,+email");
    // Should skip empty segments and succeed
    let result = result.unwrap();
    assert_eq!(result.0.len(), 2);
    assert_eq!(result.0[0].field, "name");
    assert_eq!(result.0[1].field, "email");

    // Test implicit asc direction
    let result = ODataOrderBy::from_signed_tokens("name").unwrap();
    assert_eq!(result.0.len(), 1);
    assert_eq!(result.0[0].field, "name");
    assert_eq!(result.0[0].dir, SortDir::Asc);
}

#[test]
fn test_unified_error_handling() {
    // Test cursor decode with unified error
    let invalid_cursor = "invalid_base64!";
    let result = CursorV1::decode(invalid_cursor);
    assert!(result.is_err());
    assert!(matches!(result.unwrap_err(), Error::CursorInvalidBase64));

    // Test that all cursor errors use the unified Error type
    let invalid_json = base64_url::encode(b"not_json");
    let result = CursorV1::decode(&invalid_json);
    assert!(matches!(result.unwrap_err(), Error::CursorInvalidJson));
}

#[test]
fn test_error_messages() {
    // Test that error messages are descriptive
    let filter_err = Error::InvalidFilter("malformed expression".to_owned());
    assert_eq!(
        filter_err.to_string(),
        "invalid $filter: malformed expression"
    );

    let cursor_err = Error::CursorInvalidBase64;
    assert_eq!(
        cursor_err.to_string(),
        "invalid cursor: invalid base64url encoding"
    );

    let orderby_err = Error::InvalidOrderByField("unknown_field".to_owned());
    assert_eq!(
        orderby_err.to_string(),
        "unsupported $orderby field: unknown_field"
    );
}

#[test]
fn test_parse_filter_string_error_contains_position() {
    let err = crate::parse_filter_string("name eq AND broken").unwrap_err();
    let msg = err.to_string();
    // The error must include PEG position info, not just "Parsing"
    assert!(
        msg.contains("error at") && msg.contains("expected"),
        "InvalidFilter should contain position and expectation info, got: {msg}"
    );
}