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