ic-query 0.93.0

Internet Computer query library for CloudEngine, NNS, SNS, ICRC, system canisters, and public network metadata
Documentation
use super::*;
use ic_platform::candid::contract::Principal;
use serde_json::json;

#[test]
fn empty_first_page_initializes_collection_and_retains_its_balance() {
    let mut state =
        AccountTransactionCollectionState::new(Principal::management_canister().to_text());
    assert!(state.ingest(page(&[], None), 2).unwrap());
    let mut later_page = page(&[], None);
    later_page.balance = "200".to_string();
    assert!(state.ingest(later_page, 2).unwrap());
    let changed_error = state
        .ingest(page(&[], Some("1")), 2)
        .expect_err("oldest id must stay absent after an empty first page");
    assert!(matches!(
        changed_error,
        IcrcAccountTransactionError::IncompleteCollection { .. }
    ));
    let complete = state.into_complete("TEST".to_string(), 8).unwrap();
    assert_eq!(complete.balance, "100");
    assert_eq!(
        complete.transactions,
        Vec::<IcrcAccountTransactionRow>::new()
    );
    assert_eq!(complete.page_count, 2);
}

#[test]
fn collection_state_requires_stable_exhausting_unique_pages() {
    let mut state =
        AccountTransactionCollectionState::new(Principal::management_canister().to_text());

    assert!(
        !state
            .ingest(page(&["10", "9"], Some("8")), 3)
            .expect("short non-exhausting first page")
    );
    assert!(
        state
            .ingest(page(&["8"], Some("8")), 2)
            .expect("exhausting page")
    );
    let complete = state
        .into_complete("TEST".to_string(), 8)
        .expect("unique complete collection");

    assert_eq!(complete.page_count, 2);
    assert_eq!(
        complete
            .transactions
            .iter()
            .map(|transaction| transaction.id.as_str())
            .collect::<Vec<_>>(),
        vec!["10", "9", "8"]
    );
}

#[test]
fn collection_state_rejects_overlapping_rows_and_changed_oldest_id() {
    let mut duplicate =
        AccountTransactionCollectionState::new(Principal::management_canister().to_text());
    duplicate
        .ingest(page(&["10", "9"], Some("8")), 2)
        .expect("first page");
    let duplicate_error = duplicate
        .ingest(page(&["9", "8"], Some("8")), 2)
        .expect_err("overlap violates the exclusive page cursor");
    assert!(matches!(
        duplicate_error,
        IcrcAccountTransactionError::IncompleteCollection {
            pages_fetched: 1,
            rows_fetched: 2,
            last_cursor: Some(cursor),
            ..
        } if cursor == "9"
    ));

    let mut changed_oldest =
        AccountTransactionCollectionState::new(Principal::management_canister().to_text());
    changed_oldest
        .ingest(page(&["10", "9"], Some("1")), 2)
        .expect("first page");
    let changed_error = changed_oldest
        .ingest(page(&["8"], Some("2")), 2)
        .expect_err("changed oldest transaction id");
    assert!(matches!(
        changed_error,
        IcrcAccountTransactionError::IncompleteCollection {
            reason,
            ..
        } if reason.contains("oldest transaction id changed")
    ));
}

#[test]
fn invalid_later_pages_preserve_accepted_progress_and_rows() {
    let mut fresh = AccountTransactionCollectionState::new(Principal::anonymous().to_text());
    let error = fresh
        .ingest(page(&["10", "11"], Some("1")), 2)
        .expect_err("invalid first page cannot capture a balance or progress");
    assert!(matches!(
        error,
        IcrcAccountTransactionError::IncompleteCollection {
            pages_fetched: 0,
            rows_fetched: 0,
            last_cursor: None,
            ..
        }
    ));
    let mut empty = page(&[], None);
    empty.balance = "200".into();
    assert!(fresh.ingest(empty, 2).unwrap());
    assert_eq!(
        fresh.into_complete("TEST".into(), 8).unwrap().balance,
        "200"
    );

    for invalid in [
        page(&["8", "7", "1"], Some("1")),
        page(&["7", "8"], Some("1")),
        page(&["9", "8"], Some("1")),
        page(&["11", "8"], Some("1")),
        page(&["08"], Some("1")),
        page(&["0"], Some("1")),
        page(&["8"], None),
        page(&["8", "8"], Some("1")),
    ] {
        let mut state = AccountTransactionCollectionState::new(Principal::anonymous().to_text());
        assert!(!state.ingest(page(&["10", "9"], Some("1")), 2).unwrap());
        let error = state.ingest(invalid, 2).expect_err("invalid later page");
        assert!(matches!(
            error,
            IcrcAccountTransactionError::IncompleteCollection {
                pages_fetched: 1,
                rows_fetched: 2,
                last_cursor: Some(cursor),
                ..
            } if cursor == "9"
        ));
        assert!(state.ingest(page(&["8", "1"], Some("1")), 2).unwrap());
        let complete = state.into_complete("TEST".into(), 8).unwrap();
        assert_eq!(complete.page_count, 2);
        assert_eq!(
            complete
                .transactions
                .iter()
                .map(|row| row.id.as_str())
                .collect::<Vec<_>>(),
            vec!["10", "9", "8", "1"]
        );
    }
}

#[test]
fn collection_keeps_sparse_arbitrary_size_ids_in_page_order() {
    let mut state = AccountTransactionCollectionState::new(Principal::anonymous().to_text());
    assert!(
        !state
            .ingest(
                page(&["18446744073709551617", "18446744073709551616"], Some("9")),
                2,
            )
            .unwrap()
    );
    assert!(state.ingest(page(&["10", "9"], Some("9")), 2).unwrap());
    let complete = state.into_complete("TEST".into(), 8).unwrap();
    assert_eq!(
        complete
            .transactions
            .iter()
            .map(|row| row.id.as_str())
            .collect::<Vec<_>>(),
        vec!["18446744073709551617", "18446744073709551616", "10", "9"]
    );
}

#[test]
fn collection_page_error_retains_the_resolved_index() {
    let index_canister_id = Principal::management_canister().to_text();
    let state = AccountTransactionCollectionState::new(index_canister_id.clone());

    let error = state.page_error(IcrcAccountTransactionError::InvalidCursor {
        value: "bad".to_string(),
        reason: "fixture".to_string(),
    });

    assert!(matches!(
        error,
        IcrcAccountTransactionError::CollectionPage {
            index_canister_id: Some(actual),
            pages_fetched: 0,
            rows_fetched: 0,
            last_cursor: None,
            ..
        } if actual == index_canister_id
    ));
}

fn page(transaction_ids: &[&str], oldest_transaction_id: Option<&str>) -> AccountTransactionsPage {
    AccountTransactionsPage {
        balance: "100".to_string(),
        oldest_transaction_id: oldest_transaction_id.map(str::to_string),
        transactions: transaction_ids
            .iter()
            .map(|id| IcrcAccountTransactionRow {
                id: (*id).to_string(),
                kind: "transfer".to_string(),
                timestamp_unix_nanos: None,
                amount_base_units: None,
                fee_base_units: None,
                from: None,
                to: None,
                spender: None,
                memo_hex: None,
                created_at_time_unix_nanos: None,
                expires_at_unix_nanos: None,
                expected_allowance_base_units: None,
                raw_transaction: json!({"kind": "transfer"}),
            })
            .collect(),
    }
}