ic-query 0.22.0

Internet Computer query library for NNS, SNS, ICRC, system canisters, and public network metadata
Documentation
use super::*;
use crate::{
    QueryProgress,
    icrc::{
        IcrcAccountTransactionCollectionSource, IcrcAccountTransactionRow,
        IcrcAccountTransactionSort,
    },
    test_support::temp_dir,
};
use serde_json::json;
use std::{
    fs,
    sync::atomic::{AtomicUsize, Ordering},
};

const LEDGER_CANISTER_ID: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";
const INDEX_CANISTER_ID: &str = "qhbym-qaaaa-aaaaa-aaafq-cai";
const ACCOUNT_OWNER: &str = "aaaaa-aa";

#[test]
fn cache_entity_changes_with_collection_identity_not_view_options() {
    let first = IcrcAccountTransactionCacheRequest::new(
        "/tmp/ic-query-cache",
        "https://icp-api.io",
        "ryjl3-tyaaa-aaaaa-aaaba-cai",
        "aaaaa-aa",
    );
    let same = first.clone();
    let different_endpoint = IcrcAccountTransactionCacheRequest {
        source_endpoint: "https://example.com".to_string(),
        ..first.clone()
    };

    assert_eq!(cache_entity(&first), cache_entity(&same));
    assert_ne!(cache_entity(&first), cache_entity(&different_endpoint));
}

#[test]
fn complete_refresh_publishes_canonical_cache_and_cache_only_views() {
    let root = temp_dir("ic-query-icrc-account-refresh");
    let cache = cache_request(&root);
    let request = refresh_request(cache.clone(), 1_700_000_000);
    let source = SuccessSource::new(vec![row("12"), row("10"), row("2")]);

    let refresh = refresh_icrc_account_transaction_cache_with_source(&request, &source)
        .expect("refresh complete cache");
    let cached = load_cached_icrc_account_transactions(&cache).expect("load complete cache");
    let oldest = build_icrc_account_transaction_list_report(
        &IcrcAccountTransactionListRequest::new(cache.clone(), 2)
            .with_sort(IcrcAccountTransactionSort::Oldest),
    )
    .expect("list cache oldest first");
    let status =
        build_icrc_account_transaction_cache_status_report(&cache).expect("cache status report");

    assert!(!refresh.point_in_time_guaranteed);
    assert_eq!(refresh.transaction_count, 3);
    assert_eq!(cached.snapshot.completeness.status, "api_exhausted");
    assert_eq!(cached.snapshot.completeness.row_count, 3);
    assert_eq!(
        oldest
            .transactions
            .iter()
            .map(|transaction| transaction.id.as_str())
            .collect::<Vec<_>>(),
        vec!["2", "10"]
    );
    assert_eq!(oldest.total_transaction_count, 3);
    assert_eq!(
        status.cache.as_ref().expect("cache summary").cache_status,
        "ok"
    );
    assert_eq!(
        status
            .latest_attempt
            .as_ref()
            .expect("refresh attempt")
            .status,
        "complete"
    );
    assert_eq!(source.calls.load(Ordering::Relaxed), 1);

    let _ = fs::remove_dir_all(root);
}

#[test]
fn failed_auto_discovered_refresh_preserves_cache_and_resolved_index_evidence() {
    let root = temp_dir("ic-query-icrc-account-refresh-failure");
    let cache = cache_request(&root);
    let first_request = refresh_request(cache.clone(), 1_700_000_000);
    refresh_icrc_account_transaction_cache_with_source(
        &first_request,
        &SuccessSource::new(vec![row("7")]),
    )
    .expect("seed complete cache");
    let path = icrc_account_transaction_cache_path(&cache).expect("cache path");
    let before = fs::read(&path).expect("read seeded cache");
    let failed_request = refresh_request(cache.clone(), 1_700_000_001);

    let error =
        refresh_icrc_account_transaction_cache_with_source(&failed_request, &IncompleteSource)
            .expect_err("incomplete refresh must fail");
    let after = fs::read(&path).expect("read preserved cache");
    let status =
        build_icrc_account_transaction_cache_status_report(&cache).expect("cache status report");

    assert!(matches!(
        error,
        IcrcAccountTransactionError::IncompleteCollection {
            index_canister_id,
            pages_fetched: 2,
            rows_fetched: 100,
            ..
        } if index_canister_id.as_deref() == Some(INDEX_CANISTER_ID)
    ));
    assert_eq!(after, before);
    assert_eq!(
        status.cache.as_ref().expect("preserved cache").cache_status,
        "ok"
    );
    let attempt = status.latest_attempt.expect("failed attempt");
    assert_eq!(attempt.status, "failed");
    assert_eq!(attempt.pages_fetched, 2);
    assert_eq!(attempt.rows_fetched, 100);
    assert_eq!(attempt.last_cursor.as_deref(), Some("50"));
    assert_eq!(
        attempt.index_canister_id.as_deref(),
        Some(INDEX_CANISTER_ID)
    );

    let _ = fs::remove_dir_all(root);
}

#[test]
fn source_claiming_completion_with_wrong_final_cursor_is_not_published() {
    let root = temp_dir("ic-query-icrc-account-invalid-completion");
    let cache = cache_request(&root);
    let request = refresh_request(cache.clone(), 1_700_000_000);

    let error = refresh_icrc_account_transaction_cache_with_source(&request, &WrongCursorSource)
        .expect_err("invalid completion evidence must fail");

    assert!(matches!(
        error,
        IcrcAccountTransactionError::IncompleteCollection {
            reason,
            ..
        } if reason.contains("final cursor")
    ));
    assert!(
        !icrc_account_transaction_cache_path(&cache)
            .expect("cache path")
            .exists()
    );

    let _ = fs::remove_dir_all(root);
}

#[test]
fn custom_source_must_return_the_explicitly_requested_index() {
    let root = temp_dir("ic-query-icrc-account-index-mismatch");
    let cache = cache_request(&root);
    let request = refresh_request(cache.clone(), 1_700_000_000)
        .with_index_canister_id(Principal::management_canister().to_text());

    let error = refresh_icrc_account_transaction_cache_with_source(
        &request,
        &SuccessSource::new(vec![row("7")]),
    )
    .expect_err("source index must match explicit request");

    assert!(matches!(
        error,
        IcrcAccountTransactionError::CollectionIndexMismatch {
            expected_index_canister_id,
            actual_index_canister_id,
        } if expected_index_canister_id == Principal::management_canister().to_text()
            && actual_index_canister_id == INDEX_CANISTER_ID
    ));
    assert!(
        !icrc_account_transaction_cache_path(&cache)
            .expect("cache path")
            .exists()
    );

    let _ = fs::remove_dir_all(root);
}

#[test]
fn missing_and_stale_refresh_policies_do_not_masquerade_as_each_other() {
    let root = temp_dir("ic-query-icrc-account-refresh-policy");
    let cache = cache_request(&root);
    let source = SuccessSource::new(vec![row("1")]);
    let request = refresh_request(cache.clone(), 1_700_000_000);

    load_or_refresh_missing_icrc_account_transactions_with_source(&request, &source)
        .expect("refresh missing cache");
    load_or_refresh_missing_icrc_account_transactions_with_source(&request, &source)
        .expect("reuse present cache");
    assert_eq!(source.calls.load(Ordering::Relaxed), 1);

    let stale_request = refresh_request(cache, 2_000_000_000);
    load_or_refresh_stale_icrc_account_transactions_with_source(&stale_request, 60, &source)
        .expect("refresh stale cache");
    assert_eq!(source.calls.load(Ordering::Relaxed), 2);

    let _ = fs::remove_dir_all(root);
}

fn cache_request(root: &Path) -> IcrcAccountTransactionCacheRequest {
    IcrcAccountTransactionCacheRequest::new(
        root,
        "https://icp-api.io",
        LEDGER_CANISTER_ID,
        ACCOUNT_OWNER,
    )
}

fn refresh_request(
    cache: IcrcAccountTransactionCacheRequest,
    now_unix_secs: u64,
) -> IcrcAccountTransactionRefreshRequest {
    IcrcAccountTransactionRefreshRequest::new(cache, now_unix_secs, 100, 1_800)
}

fn row(id: &str) -> IcrcAccountTransactionRow {
    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"}),
    }
}

struct SuccessSource {
    calls: AtomicUsize,
    transactions: Vec<IcrcAccountTransactionRow>,
}

impl SuccessSource {
    fn new(transactions: Vec<IcrcAccountTransactionRow>) -> Self {
        Self {
            calls: AtomicUsize::new(0),
            transactions,
        }
    }
}

impl IcrcAccountTransactionCollectionSource for SuccessSource {
    fn fetch_complete_account_transactions(
        &self,
        _request: &IcrcAccountTransactionRefreshRequest,
        _progress: &mut (dyn QueryProgress + Send),
    ) -> Result<IcrcAccountTransactionCollectionData, IcrcAccountTransactionError> {
        self.calls.fetch_add(1, Ordering::Relaxed);
        Ok(IcrcAccountTransactionCollectionData {
            index_canister_id: INDEX_CANISTER_ID.to_string(),
            balance: "42".to_string(),
            token_symbol: "ICP".to_string(),
            decimals: 8,
            transactions: self.transactions.clone(),
            page_count: 1,
            last_cursor: self
                .transactions
                .last()
                .map(|transaction| transaction.id.clone()),
        })
    }
}

struct IncompleteSource;

impl IcrcAccountTransactionCollectionSource for IncompleteSource {
    fn fetch_complete_account_transactions(
        &self,
        request: &IcrcAccountTransactionRefreshRequest,
        _progress: &mut (dyn QueryProgress + Send),
    ) -> Result<IcrcAccountTransactionCollectionData, IcrcAccountTransactionError> {
        assert!(request.index_canister_id.is_none());
        Err(IcrcAccountTransactionError::IncompleteCollection {
            index_canister_id: Some(INDEX_CANISTER_ID.to_string()),
            pages_fetched: 2,
            rows_fetched: 100,
            last_cursor: Some("50".to_string()),
            reason: "fixture stopped before API exhaustion".to_string(),
        })
    }
}

struct WrongCursorSource;

impl IcrcAccountTransactionCollectionSource for WrongCursorSource {
    fn fetch_complete_account_transactions(
        &self,
        _request: &IcrcAccountTransactionRefreshRequest,
        _progress: &mut (dyn QueryProgress + Send),
    ) -> Result<IcrcAccountTransactionCollectionData, IcrcAccountTransactionError> {
        Ok(IcrcAccountTransactionCollectionData {
            index_canister_id: INDEX_CANISTER_ID.to_string(),
            balance: "42".to_string(),
            token_symbol: "ICP".to_string(),
            decimals: 8,
            transactions: vec![row("7")],
            page_count: 1,
            last_cursor: Some("6".to_string()),
        })
    }
}