ic-query 0.100.0

Internet Computer query library for CloudEngine, NNS, SNS, ICRC, system canisters, and public network metadata
Documentation
use super::*;
use crate::nns::governance::NnsGovernanceCollectionStatus;
use crate::nns::proposals::{
    NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION, NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION,
    NnsProposalActivityError, NnsProposalActivityReport, NnsProposalActivityRequest,
    NnsProposalCollectionState, advance_nns_proposal_collection_with_source,
    build_nns_proposal_activity_report,
};

struct PagedSource(bool);

impl NnsProposalSource for PagedSource {
    fn fetch_proposals<'a>(
        &'a self,
        request: &'a NnsGovernanceRequest,
        limit: u32,
        before: Option<u64>,
        status: NnsProposalStatusFilter,
        reward: NnsProposalRewardStatusFilter,
    ) -> NnsProposalSourceFuture<'a, Vec<NnsProposalRow>> {
        Box::pin(async move {
            assert_eq!(request.network, MAINNET_NETWORK);
            assert_ne!(request.fetched_at, "");
            if let NnsGovernanceSourceSelection::ReplicaQuery {
                endpoint,
                fetched_by,
            } = &request.source
            {
                assert_eq!(endpoint, DEFAULT_MAINNET_ENDPOINT);
                assert_eq!(fetched_by, "ic-query");
            }
            assert_eq!(limit, 2);
            assert_eq!(status, NnsProposalStatusFilter::Any);
            assert_eq!(reward, NnsProposalRewardStatusFilter::Any);
            let ids = match before {
                None => vec![3, 2],
                Some(2) => vec![1],
                other => panic!("unexpected cursor: {other:?}"),
            };
            let provenance = match &request.source {
                NnsGovernanceSourceSelection::ReplicaQuery { .. } => fixture_provenance(request),
                NnsGovernanceSourceSelection::ReplicatedInterCanisterCall => {
                    NnsGovernanceSourceProvenance::ReplicatedInterCanisterCall {
                        collector_canister_id: if self.0 && before.is_some() {
                            "2vxsx-fae"
                        } else {
                            "aaaaa-aa"
                        }
                        .to_string(),
                    }
                }
            };
            Ok(NnsGovernanceSourceData::new(
                ids.into_iter()
                    .map(|id| {
                        nns_proposal_row_from_info(proposal_info(
                            id,
                            4,
                            4,
                            "Collection fixture",
                            20,
                        ))
                    })
                    .collect(),
                provenance,
            ))
        })
    }
    fn fetch_proposal<'a>(
        &'a self,
        _: &'a NnsGovernanceRequest,
        _: u64,
    ) -> NnsProposalSourceFuture<'a, NnsProposalRow> {
        panic!("collection does not fetch detail")
    }
}

#[tokio::test(flavor = "current_thread")]
async fn proposal_collection_state_resumes_until_api_exhaustion() {
    let first_request = proposal_governance_request(1_700_000_000);
    let state = NnsProposalCollectionState::new(&first_request, 2, 3)
        .expect("valid proposal collection state");
    let first =
        advance_nns_proposal_collection_with_source(&first_request, &state, &PagedSource(false))
            .await
            .expect("first proposal collection page");

    assert_eq!(
        NNS_PROPOSAL_COLLECTION_STATE_SCHEMA_VERSION,
        first.state.schema_version()
    );
    assert_eq!(
        first.state.status(),
        NnsGovernanceCollectionStatus::Collecting
    );
    assert_eq!(first.state.pages_fetched(), 1);
    let proposals_fetched: u64 = first.state.proposals_fetched();
    assert_eq!(proposals_fetched, 2);
    assert_eq!(first.state.next_before_proposal_id(), Some(2));
    assert_eq!(first.state.network(), "ic");
    assert_eq!(
        first.state.governance_canister_id(),
        "rrkah-fqaaa-aaaaa-aaaaq-cai"
    );
    assert_eq!(first.state.requested_source(), &first_request.source);
    assert_eq!(
        first.state.source(),
        Some(&fixture_provenance(&first_request))
    );
    assert_eq!(first.state.page_size(), 2);
    assert!(!first.state.is_complete());

    let persisted = serde_json::to_string(&first.state).expect("serialize collection state");
    let persisted_json: serde_json::Value =
        serde_json::from_str(&persisted).expect("parse serialized collection state");
    assert_eq!(persisted_json["schema_version"], 1);
    assert_eq!(
        persisted_json["requested_source"]["source_transport"],
        "replica_query"
    );
    let resumed: NnsProposalCollectionState =
        serde_json::from_str(&persisted).expect("deserialize collection state");
    let second_request = proposal_governance_request(1_700_000_100);
    let second =
        advance_nns_proposal_collection_with_source(&second_request, &resumed, &PagedSource(false))
            .await
            .expect("final proposal collection page");

    assert_eq!(second.page.proposals.len(), 1);
    assert_eq!(
        second.state.status(),
        NnsGovernanceCollectionStatus::Complete
    );
    assert_eq!(second.state.pages_fetched(), 2);
    assert_eq!(second.state.proposals_fetched(), 3);
    assert_eq!(second.state.next_before_proposal_id(), None);
    assert_eq!(second.state.started_at(), "2023-11-14T22:13:20Z");
    assert_eq!(second.state.updated_at(), "2023-11-14T22:15:00Z");
    assert!(second.state.is_complete());

    assert_proposal_activity(&first.page.proposals, &second.page.proposals, &second.state);

    let restored: NnsProposalCollectionState = serde_json::from_value(
        serde_json::to_value(&second.state).expect("serialize completed state"),
    )
    .expect("restore completed state");
    let error = advance_nns_proposal_collection_with_source(
        &second_request,
        &restored,
        &PagedSource(false),
    )
    .await
    .expect_err("complete collection cannot make another source call");
    assert!(matches!(
        error,
        NnsProposalError::CollectionComplete { pages_fetched: 2 }
    ));
}

#[tokio::test(flavor = "current_thread")]
async fn proposal_collection_stops_at_explicit_page_limit() {
    let request = proposal_governance_request(1_700_000_000);
    let state = NnsProposalCollectionState::new(&request, 2, 1).expect("bounded collection state");
    let step = advance_nns_proposal_collection_with_source(&request, &state, &PagedSource(false))
        .await
        .expect("bounded collection page");

    assert_eq!(
        step.state.status(),
        NnsGovernanceCollectionStatus::PageLimitReached
    );
    assert_eq!(step.state.max_pages(), 1);
    assert!(!step.state.is_complete());
    let restored: NnsProposalCollectionState = serde_json::from_value(
        serde_json::to_value(&step.state).expect("serialize page-limited state"),
    )
    .expect("restore page-limited state");
    let error =
        advance_nns_proposal_collection_with_source(&request, &restored, &PagedSource(false))
            .await
            .expect_err("page-limited collection cannot advance");
    assert!(matches!(
        error,
        NnsProposalError::CollectionPageLimitReached {
            pages_fetched: 1,
            max_pages: 1
        }
    ));

    let changed_request = NnsGovernanceRequest::replica_query_from_unix_secs(
        "ic",
        "https://example.com",
        1_700_000_100,
        "ic-query",
    );
    let error =
        advance_nns_proposal_collection_with_source(&changed_request, &state, &PagedSource(false))
            .await
            .expect_err("collection identity must be fixed before source invocation");
    assert!(matches!(
        error,
        NnsProposalError::CollectionRequestMismatch {
            field: "requested_source",
            ..
        }
    ));
}

#[tokio::test(flavor = "current_thread")]
async fn proposal_collection_rejects_a_changed_canister_collector() {
    let first_request =
        NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 1_700_000_000);
    let state = NnsProposalCollectionState::new(&first_request, 2, 3)
        .expect("valid canister collection state");
    let first =
        advance_nns_proposal_collection_with_source(&first_request, &state, &PagedSource(true))
            .await
            .expect("first canister proposal page");
    let second_request =
        NnsGovernanceRequest::replicated_inter_canister_call_from_unix_secs("ic", 1_700_000_100);

    let error = advance_nns_proposal_collection_with_source(
        &second_request,
        &first.state,
        &PagedSource(true),
    )
    .await
    .expect_err("collector principal must remain fixed");
    assert!(matches!(
        error,
        NnsProposalError::CollectionSourceChanged { .. }
    ));
}

fn assert_proposal_activity(
    first_page: &[NnsProposalRow],
    final_page: &[NnsProposalRow],
    state: &NnsProposalCollectionState,
) {
    let proposals = first_page
        .iter()
        .chain(final_page)
        .cloned()
        .collect::<Vec<_>>();
    let activity: NnsProposalActivityReport = build_nns_proposal_activity_report(
        &NnsProposalActivityRequest::default(),
        state,
        &proposals,
    )
    .expect("portable proposal activity report");
    assert_eq!(
        activity.context.schema_version,
        NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION
    );
    assert_eq!(activity.included_proposal_count, 3);
    assert!(!activity.context.point_in_time_guaranteed);
    let activity_json = serde_json::to_value(&activity).expect("serialize activity report");
    assert_eq!(activity_json["source"]["source_transport"], "replica_query");

    let invalid_window = NnsProposalActivityRequest {
        from_proposal_timestamp_seconds: Some(10),
        until_proposal_timestamp_seconds: Some(10),
    };
    assert!(matches!(
        build_nns_proposal_activity_report(&invalid_window, state, &proposals),
        Err(NnsProposalActivityError::InvalidTimeWindow { .. })
    ));
}