Files
nym/contracts/node-families/src/queries.rs
T

2507 lines
91 KiB
Rust

// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
use crate::helpers::normalise_family_name;
use crate::storage::{retrieval_limits, NodeFamiliesStorage};
use cosmwasm_std::{Deps, Env, Order, StdResult};
use cw_storage_plus::Bound;
use nym_mixnet_contract_common::NodeId;
use nym_node_families_contract_common::{
AllFamilyMembersPagedResponse, AllPastFamilyInvitationsPagedResponse, Config,
FamiliesPagedResponse, FamilyMemberRecord, FamilyMembersPagedResponse,
GlobalPastFamilyInvitationCursor, NodeFamiliesContractError, NodeFamilyByNameResponse,
NodeFamilyByOwnerResponse, NodeFamilyId, NodeFamilyMembershipResponse, NodeFamilyResponse,
PastFamilyInvitationCursor, PastFamilyInvitationForNodeCursor,
PastFamilyInvitationsForNodePagedResponse, PastFamilyInvitationsPagedResponse,
PastFamilyMemberCursor, PastFamilyMemberForNodeCursor, PastFamilyMembersForNodePagedResponse,
PastFamilyMembersPagedResponse, PendingFamilyInvitationDetails,
PendingFamilyInvitationResponse, PendingFamilyInvitationsPagedResponse,
PendingInvitationsForNodePagedResponse, PendingInvitationsPagedResponse,
};
/// Retrieve current contract configuration values
pub fn query_config(deps: Deps) -> Result<Config, NodeFamiliesContractError> {
Ok(NodeFamiliesStorage::new().config.load(deps.storage)?)
}
/// Resolve a single family by its id. Returns `family: None` if no family
/// with that id exists.
pub fn query_family_by_id(
deps: Deps,
family_id: NodeFamilyId,
) -> Result<NodeFamilyResponse, NodeFamiliesContractError> {
let family = NodeFamiliesStorage::new()
.families
.may_load(deps.storage, family_id)?;
Ok(NodeFamilyResponse { family_id, family })
}
/// Resolve the (at most one) family owned by `owner`. Returns `family: None`
/// if `owner` does not currently own a family. Backed by the `owner`
/// `UniqueIndex`, so cost is O(1).
pub fn query_family_by_owner(
deps: Deps,
owner: String,
) -> Result<NodeFamilyByOwnerResponse, NodeFamiliesContractError> {
let owner = deps.api.addr_validate(&owner)?;
let family = NodeFamiliesStorage::new()
.families
.idx
.owner
.item(deps.storage, owner.clone())?
.map(|(_, family)| family);
Ok(NodeFamilyByOwnerResponse { owner, family })
}
/// Resolve a single family by its name. The lookup runs the input through
/// [`normalise_family_name`] before hitting the `name` `UniqueIndex`, so
/// e.g. `"foo"`, `"FoO"` and `" foo! "` all resolve to the same family.
/// Returns `family: None` if no family with that (normalised) name exists.
/// Backed by the `name` `UniqueIndex`, so cost is O(1).
pub fn query_family_by_name(
deps: Deps,
name: String,
) -> Result<NodeFamilyByNameResponse, NodeFamiliesContractError> {
let normalised_name = normalise_family_name(&name);
let family = NodeFamiliesStorage::new()
.families
.idx
.normalised_name
.item(deps.storage, normalised_name)?
.map(|(_, family)| family);
Ok(NodeFamilyByNameResponse { name, family })
}
/// Report which family — if any — a node currently belongs to.
pub fn query_family_membership(
deps: Deps,
node_id: NodeId,
) -> Result<NodeFamilyMembershipResponse, NodeFamiliesContractError> {
let family_id = NodeFamiliesStorage::new()
.family_members
.may_load(deps.storage, node_id)?
.map(|m| m.family_id);
Ok(NodeFamilyMembershipResponse { node_id, family_id })
}
/// Resolve a pending invitation by its composite `(family_id, node_id)` key,
/// stamping it with whether it has already timed out at the current block
/// time so the caller doesn't have to do the comparison itself.
pub fn query_pending_invitation(
deps: Deps,
env: Env,
family_id: NodeFamilyId,
node_id: NodeId,
) -> Result<PendingFamilyInvitationResponse, NodeFamiliesContractError> {
let now = env.block.time.seconds();
let invitation = NodeFamiliesStorage::new()
.pending_family_invitations
.may_load(deps.storage, (family_id, node_id))?
.map(|invitation| PendingFamilyInvitationDetails {
expired: now >= invitation.expires_at,
invitation,
});
Ok(PendingFamilyInvitationResponse {
family_id,
node_id,
invitation,
})
}
/// Page through every node currently in `family_id`, in ascending
/// [`NodeId`] order.
///
/// Backed by the `family` multi-index over [`crate::storage::NodeFamiliesStorage::family_members`],
/// so the cost is O(page size) regardless of how many other families exist.
/// Does not verify that `family_id` refers to an existing family — an
/// unknown id simply yields an empty page.
///
/// `start_after` is exclusive — pass the previous page's `start_next_after`
/// to fetch the next page; pass `None` to start from the lowest-id member.
/// `limit` defaults to [`retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT`]
/// and is clamped to [`retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT`].
pub fn query_family_members_paged(
deps: Deps,
family_id: NodeFamilyId,
start_after: Option<NodeId>,
limit: Option<u32>,
) -> Result<FamilyMembersPagedResponse, NodeFamiliesContractError> {
let limit = limit
.unwrap_or(retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT)
.min(retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT) as usize;
let start = start_after.map(Bound::exclusive);
let storage = NodeFamiliesStorage::new();
let members = storage
.family_members
.idx
.family
.prefix(family_id)
.range(deps.storage, start, None, Order::Ascending)
.take(limit)
.map(|res| {
res.map(|(node_id, membership)| FamilyMemberRecord {
node_id,
membership,
})
})
.collect::<StdResult<Vec<_>>>()?;
let start_next_after = members.last().map(|record| record.node_id);
Ok(FamilyMembersPagedResponse {
family_id,
members,
start_next_after,
})
}
/// Page through every current family member across all families, in ascending
/// [`NodeId`] order. Each entry carries the [`FamilyMembership`](node_families_contract_common::FamilyMembership)
/// record, which names the family the node belongs to.
///
/// Cost is O(page size) — full range scan over the primary `family_members`
/// map without any prefix filter. Since each node belongs to at most one
/// family, [`NodeId`] alone is sufficient as a pagination cursor.
///
/// `start_after` is exclusive — pass the previous page's `start_next_after`
/// to fetch the next page; pass `None` to start from the lowest-id member.
/// `limit` defaults to [`retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT`]
/// and is clamped to [`retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT`].
pub fn query_all_family_members_paged(
deps: Deps,
start_after: Option<NodeId>,
limit: Option<u32>,
) -> Result<AllFamilyMembersPagedResponse, NodeFamiliesContractError> {
let limit = limit
.unwrap_or(retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT)
.min(retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT) as usize;
let start = start_after.map(Bound::exclusive);
let storage = NodeFamiliesStorage::new();
let members = storage
.family_members
.range(deps.storage, start, None, Order::Ascending)
.take(limit)
.map(|res| {
res.map(|(node_id, membership)| FamilyMemberRecord {
node_id,
membership,
})
})
.collect::<StdResult<Vec<_>>>()?;
let start_next_after = members.last().map(|record| record.node_id);
Ok(AllFamilyMembersPagedResponse {
members,
start_next_after,
})
}
/// Page through every pending invitation issued by `family_id`, in ascending
/// invitee [`NodeId`] order. Each entry is stamped with `expired` based on
/// the current block time, so callers don't have to compare it themselves.
///
/// Backed by a prefix scan on the composite primary key
/// `(family_id, node_id)` of `pending_family_invitations`, so cost is
/// O(page size). Does not verify that `family_id` refers to an existing
/// family — an unknown id simply yields an empty page.
///
/// `start_after` is exclusive — pass the previous page's `start_next_after`
/// to fetch the next page; pass `None` to start from the lowest-id invitee.
/// `limit` defaults to [`retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT`]
/// and is clamped to [`retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT`].
pub fn query_pending_invitations_for_family_paged(
deps: Deps,
env: Env,
family_id: NodeFamilyId,
start_after: Option<NodeId>,
limit: Option<u32>,
) -> Result<PendingFamilyInvitationsPagedResponse, NodeFamiliesContractError> {
let limit = limit
.unwrap_or(retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT)
.min(retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT) as usize;
let now = env.block.time.seconds();
let start = start_after.map(Bound::exclusive);
let storage = NodeFamiliesStorage::new();
let invitations = storage
.pending_family_invitations
.prefix(family_id)
.range(deps.storage, start, None, Order::Ascending)
.take(limit)
.map(|res| {
res.map(|(_node_id, invitation)| PendingFamilyInvitationDetails {
expired: now >= invitation.expires_at,
invitation,
})
})
.collect::<StdResult<Vec<_>>>()?;
let start_next_after = invitations.last().map(|d| d.invitation.node_id);
Ok(PendingFamilyInvitationsPagedResponse {
family_id,
invitations,
start_next_after,
})
}
/// Page through every pending invitation addressed to `node_id`, in ascending
/// issuing [`NodeFamilyId`] order. Each entry is stamped with `expired` based
/// on the current block time.
///
/// Backed by the `node` multi-index over `pending_family_invitations`, so
/// cost is O(page size). `start_after` is exclusive — pass the previous
/// page's `start_next_after` to fetch the next page. `limit` defaults to
/// [`retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT`] and is clamped to
/// [`retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT`].
pub fn query_pending_invitations_for_node_paged(
deps: Deps,
env: Env,
node_id: NodeId,
start_after: Option<NodeFamilyId>,
limit: Option<u32>,
) -> Result<PendingInvitationsForNodePagedResponse, NodeFamiliesContractError> {
let limit = limit
.unwrap_or(retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT)
.min(retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT) as usize;
let now = env.block.time.seconds();
let start = start_after.map(|family_id| Bound::exclusive((family_id, node_id)));
let storage = NodeFamiliesStorage::new();
let invitations = storage
.pending_family_invitations
.idx
.node
.prefix(node_id)
.range(deps.storage, start, None, Order::Ascending)
.take(limit)
.map(|res| {
res.map(|(_pk, invitation)| PendingFamilyInvitationDetails {
expired: now >= invitation.expires_at,
invitation,
})
})
.collect::<StdResult<Vec<_>>>()?;
let start_next_after = invitations.last().map(|d| d.invitation.family_id);
Ok(PendingInvitationsForNodePagedResponse {
node_id,
invitations,
start_next_after,
})
}
/// Page through every pending invitation across all families, in ascending
/// `(family_id, node_id)` order. Each entry is stamped with `expired` based
/// on the current block time.
///
/// Cost is O(page size) — full range scan over the
/// `pending_family_invitations` map without any prefix filter.
///
/// `start_after` is exclusive — pass the previous page's `start_next_after`
/// to fetch the next page; pass `None` to start from the first entry.
/// `limit` defaults to [`retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT`]
/// and is clamped to [`retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT`].
pub fn query_all_pending_invitations_paged(
deps: Deps,
env: Env,
start_after: Option<(NodeFamilyId, NodeId)>,
limit: Option<u32>,
) -> Result<PendingInvitationsPagedResponse, NodeFamiliesContractError> {
let limit = limit
.unwrap_or(retrieval_limits::PENDING_INVITATIONS_DEFAULT_LIMIT)
.min(retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT) as usize;
let now = env.block.time.seconds();
let start = start_after.map(Bound::exclusive);
let storage = NodeFamiliesStorage::new();
let invitations = storage
.pending_family_invitations
.range(deps.storage, start, None, Order::Ascending)
.take(limit)
.map(|res| {
res.map(|(_key, invitation)| PendingFamilyInvitationDetails {
expired: now >= invitation.expires_at,
invitation,
})
})
.collect::<StdResult<Vec<_>>>()?;
let start_next_after = invitations
.last()
.map(|d| (d.invitation.family_id, d.invitation.node_id));
Ok(PendingInvitationsPagedResponse {
invitations,
start_next_after,
})
}
/// Page through every archived (terminal-state) invitation issued by
/// `family_id`, in ascending `(node_id, counter)` order across all
/// `Accepted` / `Rejected` / `Revoked` statuses.
///
/// Uses a direct bounds-based range scan on the primary map keyed by
/// `((family_id, node_id), counter)` — `family_id` is already the leftmost
/// key component, so this avoids the extra storage read per entry that
/// going through the `family` multi-index would incur. Cost is O(page
/// size). Does not verify that `family_id` refers to an existing
/// family — an unknown id simply yields an empty page.
///
/// `start_after` is exclusive — pass the previous page's `start_next_after`
/// to fetch the next page; pass `None` to start from the first archived
/// entry. `limit` defaults to [`retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT`]
/// and is clamped to [`retrieval_limits::PAST_INVITATIONS_MAX_LIMIT`].
pub fn query_past_invitations_for_family_paged(
deps: Deps,
family_id: NodeFamilyId,
start_after: Option<PastFamilyInvitationCursor>,
limit: Option<u32>,
) -> Result<PastFamilyInvitationsPagedResponse, NodeFamiliesContractError> {
let limit = limit
.unwrap_or(retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT)
.min(retrieval_limits::PAST_INVITATIONS_MAX_LIMIT) as usize;
let lower = Some(match start_after {
Some((node_id, counter)) => Bound::exclusive(((family_id, node_id), counter)),
None => Bound::inclusive(((family_id, 0), 0)),
});
// upper bound = first key of next family;
let upper = Some(Bound::exclusive(((family_id + 1, 0), 0)));
let storage = NodeFamiliesStorage::new();
let entries = storage
.past_family_invitations
.range(deps.storage, lower, upper, Order::Ascending)
.take(limit)
.collect::<StdResult<Vec<_>>>()?;
let start_next_after = entries
.last()
.map(|(((_, node_id), counter), _)| (*node_id, *counter));
let invitations = entries.into_iter().map(|(_, v)| v).collect();
Ok(PastFamilyInvitationsPagedResponse {
family_id,
invitations,
start_next_after,
})
}
/// Page through every archived (terminal-state) invitation addressed to
/// `node_id`, in ascending `(family_id, counter)` order across all
/// `Accepted` / `Rejected` / `Revoked` statuses.
///
/// Backed by the `node` multi-index over `past_family_invitations`, so
/// cost is O(page size). `start_after` is exclusive — pass the previous
/// page's `start_next_after` to fetch the next page. `limit` defaults to
/// [`retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT`] and is clamped to
/// [`retrieval_limits::PAST_INVITATIONS_MAX_LIMIT`].
pub fn query_past_invitations_for_node_paged(
deps: Deps,
node_id: NodeId,
start_after: Option<PastFamilyInvitationForNodeCursor>,
limit: Option<u32>,
) -> Result<PastFamilyInvitationsForNodePagedResponse, NodeFamiliesContractError> {
let limit = limit
.unwrap_or(retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT)
.min(retrieval_limits::PAST_INVITATIONS_MAX_LIMIT) as usize;
let start =
start_after.map(|(family_id, counter)| Bound::exclusive(((family_id, node_id), counter)));
let storage = NodeFamiliesStorage::new();
let entries = storage
.past_family_invitations
.idx
.node
.prefix(node_id)
.range(deps.storage, start, None, Order::Ascending)
.take(limit)
.collect::<StdResult<Vec<_>>>()?;
let start_next_after = entries
.last()
.map(|(((family_id, _), counter), _)| (*family_id, *counter));
let invitations = entries.into_iter().map(|(_, v)| v).collect();
Ok(PastFamilyInvitationsForNodePagedResponse {
node_id,
invitations,
start_next_after,
})
}
/// Page through every archived (terminal-state) invitation across all
/// families, in ascending `((family_id, node_id), counter)` order.
///
/// Cost is O(page size) — full range scan over the
/// `past_family_invitations` map without any prefix filter.
///
/// `start_after` is exclusive — pass the previous page's `start_next_after`
/// to fetch the next page; pass `None` to start from the first entry.
/// `limit` defaults to [`retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT`]
/// and is clamped to [`retrieval_limits::PAST_INVITATIONS_MAX_LIMIT`].
pub fn query_all_past_invitations_paged(
deps: Deps,
start_after: Option<GlobalPastFamilyInvitationCursor>,
limit: Option<u32>,
) -> Result<AllPastFamilyInvitationsPagedResponse, NodeFamiliesContractError> {
let limit = limit
.unwrap_or(retrieval_limits::PAST_INVITATIONS_DEFAULT_LIMIT)
.min(retrieval_limits::PAST_INVITATIONS_MAX_LIMIT) as usize;
let start = start_after.map(Bound::exclusive);
let storage = NodeFamiliesStorage::new();
let entries = storage
.past_family_invitations
.range(deps.storage, start, None, Order::Ascending)
.take(limit)
.collect::<StdResult<Vec<_>>>()?;
let start_next_after = entries.last().map(|(key, _)| *key);
let invitations = entries.into_iter().map(|(_, v)| v).collect();
Ok(AllPastFamilyInvitationsPagedResponse {
invitations,
start_next_after,
})
}
/// Page through every archived membership record for `family_id` (nodes that
/// used to belong to it but have since been removed), in ascending
/// `(node_id, counter)` order.
///
/// Uses a direct bounds-based range scan on the primary map keyed by
/// `((family_id, node_id), counter)` — `family_id` is already the leftmost
/// key component, so this avoids the extra storage read per entry that
/// going through the `family` multi-index would incur. Cost is O(page
/// size). Does not verify that `family_id` refers to an existing
/// family — an unknown id simply yields an empty page.
///
/// `start_after` is exclusive — pass the previous page's `start_next_after`
/// to fetch the next page; pass `None` to start from the first archived
/// entry. `limit` defaults to [`retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT`]
/// and is clamped to [`retrieval_limits::PAST_MEMBERS_MAX_LIMIT`].
pub fn query_past_members_for_family_paged(
deps: Deps,
family_id: NodeFamilyId,
start_after: Option<PastFamilyMemberCursor>,
limit: Option<u32>,
) -> Result<PastFamilyMembersPagedResponse, NodeFamiliesContractError> {
let limit = limit
.unwrap_or(retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT)
.min(retrieval_limits::PAST_MEMBERS_MAX_LIMIT) as usize;
let lower = Some(match start_after {
Some((node_id, counter)) => Bound::exclusive(((family_id, node_id), counter)),
None => Bound::inclusive(((family_id, 0), 0)),
});
// upper bound = first key of next family;
let upper = Some(Bound::exclusive(((family_id + 1, 0), 0)));
let storage = NodeFamiliesStorage::new();
let entries = storage
.past_family_members
.range(deps.storage, lower, upper, Order::Ascending)
.take(limit)
.collect::<StdResult<Vec<_>>>()?;
let start_next_after = entries
.last()
.map(|(((_, node_id), counter), _)| (*node_id, *counter));
let members = entries.into_iter().map(|(_, v)| v).collect();
Ok(PastFamilyMembersPagedResponse {
family_id,
members,
start_next_after,
})
}
/// Page through every archived membership record for `node_id` (every family
/// the node used to belong to but has since been removed from), in ascending
/// `(family_id, counter)` order.
///
/// Backed by the `node` multi-index over `past_family_members`, so cost is
/// O(page size). `start_after` is exclusive — pass the previous page's
/// `start_next_after` to fetch the next page. `limit` defaults to
/// [`retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT`] and is clamped to
/// [`retrieval_limits::PAST_MEMBERS_MAX_LIMIT`].
pub fn query_past_members_for_node_paged(
deps: Deps,
node_id: NodeId,
start_after: Option<PastFamilyMemberForNodeCursor>,
limit: Option<u32>,
) -> Result<PastFamilyMembersForNodePagedResponse, NodeFamiliesContractError> {
let limit = limit
.unwrap_or(retrieval_limits::PAST_MEMBERS_DEFAULT_LIMIT)
.min(retrieval_limits::PAST_MEMBERS_MAX_LIMIT) as usize;
let start =
start_after.map(|(family_id, counter)| Bound::exclusive(((family_id, node_id), counter)));
let storage = NodeFamiliesStorage::new();
let entries = storage
.past_family_members
.idx
.node
.prefix(node_id)
.range(deps.storage, start, None, Order::Ascending)
.take(limit)
.collect::<StdResult<Vec<_>>>()?;
let start_next_after = entries
.last()
.map(|(((family_id, _), counter), _)| (*family_id, *counter));
let members = entries.into_iter().map(|(_, v)| v).collect();
Ok(PastFamilyMembersForNodePagedResponse {
node_id,
members,
start_next_after,
})
}
/// Page through every existing family in ascending [`NodeFamilyId`] order.
///
/// `start_after` is exclusive — pass the previous page's `start_next_after`
/// to fetch the next page; pass `None` to start from the first family.
/// `limit` defaults to [`retrieval_limits::FAMILIES_DEFAULT_LIMIT`] and is
/// clamped to [`retrieval_limits::FAMILIES_MAX_LIMIT`].
pub fn query_families_paged(
deps: Deps,
start_after: Option<NodeFamilyId>,
limit: Option<u32>,
) -> Result<FamiliesPagedResponse, NodeFamiliesContractError> {
let limit = limit
.unwrap_or(retrieval_limits::FAMILIES_DEFAULT_LIMIT)
.min(retrieval_limits::FAMILIES_MAX_LIMIT) as usize;
let start = start_after.map(Bound::exclusive);
let storage = NodeFamiliesStorage::new();
let families = storage
.families
.range(deps.storage, start, None, Order::Ascending)
.take(limit)
.map(|res| res.map(|item| item.1))
.collect::<StdResult<Vec<_>>>()?;
let start_next_after = families.last().map(|family| family.id);
Ok(FamiliesPagedResponse {
families,
start_next_after,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{init_contract_tester, NodeFamiliesContractTesterExt};
use nym_contracts_common_testing::{ChainOpts, ContractOpts};
#[cfg(test)]
mod family_by_id {
use super::*;
#[test]
fn family_by_id_returns_none_when_missing() {
let tester = init_contract_tester();
let res = query_family_by_id(tester.deps(), 99).unwrap();
assert_eq!(res.family_id, 99);
assert!(res.family.is_none());
}
#[test]
fn family_by_id_returns_persisted_family() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let res = query_family_by_id(tester.deps(), f.id).unwrap();
assert_eq!(res.family_id, f.id);
assert_eq!(res.family, Some(f));
}
}
#[cfg(test)]
mod family_by_owner {
use super::*;
#[test]
fn returns_none_when_owner_owns_no_family() {
let tester = init_contract_tester();
let alice = tester.addr_make("alice");
let res = query_family_by_owner(tester.deps(), alice.to_string()).unwrap();
assert_eq!(res.owner, alice);
assert!(res.family.is_none());
}
#[test]
fn returns_persisted_family_for_owner() {
let mut tester = init_contract_tester();
let alice = tester.addr_make("alice");
let f = tester.make_family(&alice);
let res = query_family_by_owner(tester.deps(), alice.to_string()).unwrap();
assert_eq!(res.owner, alice);
assert_eq!(res.family, Some(f));
}
#[test]
fn distinguishes_owners() {
let mut tester = init_contract_tester();
let alice = tester.addr_make("alice");
let bob = tester.addr_make("bob");
let f_alice = tester.make_family(&alice);
let f_bob = tester.make_family(&bob);
let res = query_family_by_owner(tester.deps(), alice.to_string()).unwrap();
assert_eq!(res.family, Some(f_alice));
let res = query_family_by_owner(tester.deps(), bob.to_string()).unwrap();
assert_eq!(res.family, Some(f_bob));
}
#[test]
fn errors_on_invalid_address() {
let tester = init_contract_tester();
let res = query_family_by_owner(tester.deps(), "not a valid addr".to_string());
assert!(res.is_err());
}
}
#[cfg(test)]
mod family_by_name {
use super::*;
use nym_contracts_common_testing::RandExt;
#[test]
fn returns_none_when_name_does_not_exist() {
let tester = init_contract_tester();
let res = query_family_by_name(tester.deps(), "missing".to_string()).unwrap();
assert_eq!(res.name, "missing");
assert!(res.family.is_none());
}
#[test]
fn returns_persisted_family_by_exact_name() {
let mut tester = init_contract_tester();
let alice = tester.addr_make("alice");
let f = tester.make_named_family(&alice, "foo");
let res = query_family_by_name(tester.deps(), "foo".to_string()).unwrap();
assert_eq!(res.name, "foo");
assert_eq!(res.family, Some(f));
}
#[test]
fn lookup_normalises_name() {
let variants = ["foo", "FOO", "FoO", "fOo", "foo🚀", "🚀Foo", " foo-!"];
for family_name in variants {
let mut tester = init_contract_tester();
let owner = tester.generate_account();
let f = tester.make_named_family(&owner, family_name);
for query_name in variants {
let res = query_family_by_name(tester.deps(), query_name.to_string()).unwrap();
assert_eq!(res.name, query_name);
assert_eq!(res.family, Some(f.clone()));
let stored = res.family.unwrap();
// user-submitted formatting is preserved on the record;
// the normalised form is what enforces uniqueness.
assert_eq!(stored.name, family_name);
assert_eq!(stored.normalised_name, normalise_family_name(family_name));
}
}
}
}
#[cfg(test)]
mod family_membership {
use super::*;
#[test]
fn family_membership_returns_none_for_unaffiliated_node() {
let tester = init_contract_tester();
let res = query_family_membership(tester.deps(), 999).unwrap();
assert_eq!(res.node_id, 999);
assert!(res.family_id.is_none());
}
#[test]
fn family_membership_returns_family_id_for_member() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
tester.add_to_family(f.id, 42);
let res = query_family_membership(tester.deps(), 42).unwrap();
assert_eq!(res.node_id, 42);
assert_eq!(res.family_id, Some(f.id));
}
#[test]
fn family_membership_returns_none_after_node_is_removed() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
tester.add_to_family(f.id, 42);
tester.remove_from_family(42);
let res = query_family_membership(tester.deps(), 42).unwrap();
assert!(res.family_id.is_none());
}
}
#[cfg(test)]
mod pending_invitation {
use super::*;
#[test]
fn pending_invitation_returns_none_when_missing() {
let tester = init_contract_tester();
let env = tester.env();
let res = query_pending_invitation(tester.deps(), env, 1, 42).unwrap();
assert_eq!(res.family_id, 1);
assert_eq!(res.node_id, 42);
assert!(res.invitation.is_none());
}
#[test]
fn pending_invitation_returns_unexpired_when_in_future() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let inv = tester.invite_to_family(f.id, 42);
let env = tester.env();
let res = query_pending_invitation(tester.deps(), env, f.id, 42).unwrap();
let details = res.invitation.unwrap();
assert_eq!(details.invitation, inv);
assert!(!details.expired);
}
#[test]
fn pending_invitation_flagged_as_expired_after_block_time() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let expires_at = tester.env().block.time.seconds() + 5;
tester.invite_to_family_with_expiration(f.id, 42, expires_at);
// advance block time well past the expiry
tester.advance_time_by(60);
let env = tester.env();
assert!(env.block.time.seconds() >= expires_at);
let res = query_pending_invitation(tester.deps(), env, f.id, 42).unwrap();
let details = res.invitation.unwrap();
assert_eq!(details.invitation.expires_at, expires_at);
assert!(details.expired);
}
}
#[cfg(test)]
mod families_paged {
use super::*;
#[test]
fn empty_when_no_families_exist() {
let tester = init_contract_tester();
let res = query_families_paged(tester.deps(), None, None).unwrap();
assert!(res.families.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn returns_all_families_within_default_limit() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let f3 = tester.add_dummy_family();
let res = query_families_paged(tester.deps(), None, None).unwrap();
assert_eq!(res.families, vec![f1, f2, f3.clone()]);
assert_eq!(res.start_next_after, Some(f3.id));
}
#[test]
fn returns_families_in_ascending_id_order() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let f3 = tester.add_dummy_family();
let res = query_families_paged(tester.deps(), None, None).unwrap();
let ids: Vec<_> = res.families.iter().map(|f| f.id).collect();
assert_eq!(ids, vec![f1.id, f2.id, f3.id]);
}
#[test]
fn limit_caps_page_size() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let _f3 = tester.add_dummy_family();
let res = query_families_paged(tester.deps(), None, Some(2)).unwrap();
assert_eq!(res.families, vec![f1, f2.clone()]);
assert_eq!(res.start_next_after, Some(f2.id));
}
#[test]
fn start_after_is_exclusive() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let f3 = tester.add_dummy_family();
let res = query_families_paged(tester.deps(), Some(f1.id), None).unwrap();
assert_eq!(res.families, vec![f2, f3.clone()]);
assert_eq!(res.start_next_after, Some(f3.id));
}
#[test]
fn paginates_through_all_families() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let f3 = tester.add_dummy_family();
let f4 = tester.add_dummy_family();
let f5 = tester.add_dummy_family();
let page1 = query_families_paged(tester.deps(), None, Some(2)).unwrap();
assert_eq!(page1.families, vec![f1, f2.clone()]);
assert_eq!(page1.start_next_after, Some(f2.id));
let page2 =
query_families_paged(tester.deps(), page1.start_next_after, Some(2)).unwrap();
assert_eq!(page2.families, vec![f3, f4.clone()]);
assert_eq!(page2.start_next_after, Some(f4.id));
let page3 =
query_families_paged(tester.deps(), page2.start_next_after, Some(2)).unwrap();
assert_eq!(page3.families, vec![f5.clone()]);
assert_eq!(page3.start_next_after, Some(f5.id));
let page4 =
query_families_paged(tester.deps(), page3.start_next_after, Some(2)).unwrap();
assert!(page4.families.is_empty());
assert!(page4.start_next_after.is_none());
}
#[test]
fn limit_is_clamped_to_max() {
let mut tester = init_contract_tester();
let total = retrieval_limits::FAMILIES_MAX_LIMIT as usize + 5;
for _ in 0..total {
tester.add_dummy_family();
}
let res = query_families_paged(tester.deps(), None, Some(u32::MAX)).unwrap();
assert_eq!(
res.families.len(),
retrieval_limits::FAMILIES_MAX_LIMIT as usize
);
}
#[test]
fn default_limit_applied_when_unspecified() {
let mut tester = init_contract_tester();
let total = retrieval_limits::FAMILIES_DEFAULT_LIMIT as usize + 5;
for _ in 0..total {
tester.add_dummy_family();
}
let res = query_families_paged(tester.deps(), None, None).unwrap();
assert_eq!(
res.families.len(),
retrieval_limits::FAMILIES_DEFAULT_LIMIT as usize
);
}
#[test]
fn start_after_past_end_returns_empty() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let res = query_families_paged(tester.deps(), Some(f.id), None).unwrap();
assert!(res.families.is_empty());
assert!(res.start_next_after.is_none());
}
}
#[cfg(test)]
mod family_members_paged {
use super::*;
#[test]
fn empty_when_family_has_no_members() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap();
assert_eq!(res.family_id, f.id);
assert!(res.members.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn empty_for_unknown_family_id() {
let tester = init_contract_tester();
let res = query_family_members_paged(tester.deps(), 99, None, None).unwrap();
assert_eq!(res.family_id, 99);
assert!(res.members.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn returns_only_members_of_requested_family() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.add_to_family(f1.id, 10);
tester.add_to_family(f1.id, 11);
tester.add_to_family(f2.id, 20);
let res = query_family_members_paged(tester.deps(), f1.id, None, None).unwrap();
let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect();
assert_eq!(ids, vec![10, 11]);
for record in &res.members {
assert_eq!(record.membership.family_id, f1.id);
}
}
#[test]
fn member_record_carries_joined_at_timestamp() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
tester.add_to_family(f.id, 42);
let expected = tester.env().block.time.seconds();
let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap();
let record = res.members.into_iter().next().unwrap();
assert_eq!(record.node_id, 42);
assert_eq!(record.membership.family_id, f.id);
assert_eq!(record.membership.joined_at, expected);
}
#[test]
fn members_returned_in_ascending_node_id_order() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
// insert out of order to confirm ordering isn't insertion order
tester.add_to_family(f.id, 30);
tester.add_to_family(f.id, 10);
tester.add_to_family(f.id, 20);
let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap();
let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect();
assert_eq!(ids, vec![10, 20, 30]);
}
#[test]
fn limit_caps_page_size() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
for n in [10, 11, 12] {
tester.add_to_family(f.id, n);
}
let res = query_family_members_paged(tester.deps(), f.id, None, Some(2)).unwrap();
let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect();
assert_eq!(ids, vec![10, 11]);
assert_eq!(res.start_next_after, Some(11));
}
#[test]
fn start_after_is_exclusive() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
for n in [10, 11, 12] {
tester.add_to_family(f.id, n);
}
let res = query_family_members_paged(tester.deps(), f.id, Some(10), None).unwrap();
let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect();
assert_eq!(ids, vec![11, 12]);
assert_eq!(res.start_next_after, Some(12));
}
#[test]
fn paginates_through_all_members() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
for n in [10, 11, 12, 13, 14] {
tester.add_to_family(f.id, n);
}
let p1 = query_family_members_paged(tester.deps(), f.id, None, Some(2)).unwrap();
assert_eq!(
p1.members.iter().map(|m| m.node_id).collect::<Vec<_>>(),
vec![10, 11]
);
assert_eq!(p1.start_next_after, Some(11));
let p2 = query_family_members_paged(tester.deps(), f.id, p1.start_next_after, Some(2))
.unwrap();
assert_eq!(
p2.members.iter().map(|m| m.node_id).collect::<Vec<_>>(),
vec![12, 13]
);
assert_eq!(p2.start_next_after, Some(13));
let p3 = query_family_members_paged(tester.deps(), f.id, p2.start_next_after, Some(2))
.unwrap();
assert_eq!(
p3.members.iter().map(|m| m.node_id).collect::<Vec<_>>(),
vec![14]
);
assert_eq!(p3.start_next_after, Some(14));
let p4 = query_family_members_paged(tester.deps(), f.id, p3.start_next_after, Some(2))
.unwrap();
assert!(p4.members.is_empty());
assert!(p4.start_next_after.is_none());
}
#[test]
fn limit_is_clamped_to_max() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let total = retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT + 5;
tester.add_n_family_members(f.id, total);
let res =
query_family_members_paged(tester.deps(), f.id, None, Some(u32::MAX)).unwrap();
assert_eq!(
res.members.len(),
retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT as usize
);
}
#[test]
fn excludes_node_after_it_leaves_family() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
tester.add_to_family(f.id, 10);
tester.add_to_family(f.id, 11);
tester.remove_from_family(10);
let res = query_family_members_paged(tester.deps(), f.id, None, None).unwrap();
let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect();
assert_eq!(ids, vec![11]);
}
}
#[cfg(test)]
mod all_family_members_paged {
use super::*;
#[test]
fn empty_when_no_families_exist() {
let tester = init_contract_tester();
let res = query_all_family_members_paged(tester.deps(), None, None).unwrap();
assert!(res.members.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn empty_when_families_have_no_members() {
let mut tester = init_contract_tester();
tester.add_dummy_family();
tester.add_dummy_family();
let res = query_all_family_members_paged(tester.deps(), None, None).unwrap();
assert!(res.members.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn returns_members_from_every_family() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let f3 = tester.add_dummy_family();
tester.add_to_family(f1.id, 10);
tester.add_to_family(f2.id, 20);
tester.add_to_family(f3.id, 30);
let res = query_all_family_members_paged(tester.deps(), None, None).unwrap();
let pairs: Vec<_> = res
.members
.iter()
.map(|m| (m.node_id, m.membership.family_id))
.collect();
assert_eq!(pairs, vec![(10, f1.id), (20, f2.id), (30, f3.id)]);
}
#[test]
fn members_returned_in_ascending_node_id_order_across_families() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
// interleaved so insertion order does not match the requested ordering
tester.add_to_family(f2.id, 30);
tester.add_to_family(f1.id, 10);
tester.add_to_family(f2.id, 20);
tester.add_to_family(f1.id, 40);
let res = query_all_family_members_paged(tester.deps(), None, None).unwrap();
let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect();
assert_eq!(ids, vec![10, 20, 30, 40]);
}
#[test]
fn member_record_carries_correct_family_and_joined_at() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.add_to_family(f1.id, 7);
tester.add_to_family(f2.id, 99);
let expected_joined_at = tester.env().block.time.seconds();
let res = query_all_family_members_paged(tester.deps(), None, None).unwrap();
let by_node: std::collections::HashMap<_, _> = res
.members
.into_iter()
.map(|m| (m.node_id, m.membership))
.collect();
let m7 = &by_node[&7];
assert_eq!(m7.family_id, f1.id);
assert_eq!(m7.joined_at, expected_joined_at);
let m99 = &by_node[&99];
assert_eq!(m99.family_id, f2.id);
assert_eq!(m99.joined_at, expected_joined_at);
}
#[test]
fn limit_caps_page_size() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
for n in [10, 11, 12] {
tester.add_to_family(f.id, n);
}
let res = query_all_family_members_paged(tester.deps(), None, Some(2)).unwrap();
let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect();
assert_eq!(ids, vec![10, 11]);
assert_eq!(res.start_next_after, Some(11));
}
#[test]
fn start_after_is_exclusive() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
for n in [10, 11, 12] {
tester.add_to_family(f.id, n);
}
let res = query_all_family_members_paged(tester.deps(), Some(10), None).unwrap();
let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect();
assert_eq!(ids, vec![11, 12]);
assert_eq!(res.start_next_after, Some(12));
}
#[test]
fn paginates_through_all_members_across_families() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.add_to_family(f1.id, 10);
tester.add_to_family(f2.id, 11);
tester.add_to_family(f1.id, 12);
tester.add_to_family(f2.id, 13);
tester.add_to_family(f1.id, 14);
let p1 = query_all_family_members_paged(tester.deps(), None, Some(2)).unwrap();
assert_eq!(
p1.members.iter().map(|m| m.node_id).collect::<Vec<_>>(),
vec![10, 11]
);
assert_eq!(p1.start_next_after, Some(11));
let p2 = query_all_family_members_paged(tester.deps(), p1.start_next_after, Some(2))
.unwrap();
assert_eq!(
p2.members.iter().map(|m| m.node_id).collect::<Vec<_>>(),
vec![12, 13]
);
assert_eq!(p2.start_next_after, Some(13));
let p3 = query_all_family_members_paged(tester.deps(), p2.start_next_after, Some(2))
.unwrap();
assert_eq!(
p3.members.iter().map(|m| m.node_id).collect::<Vec<_>>(),
vec![14]
);
assert_eq!(p3.start_next_after, Some(14));
let p4 = query_all_family_members_paged(tester.deps(), p3.start_next_after, Some(2))
.unwrap();
assert!(p4.members.is_empty());
assert!(p4.start_next_after.is_none());
}
#[test]
fn limit_is_clamped_to_max() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let total = retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT + 5;
tester.add_n_family_members(f.id, total);
let res = query_all_family_members_paged(tester.deps(), None, Some(u32::MAX)).unwrap();
assert_eq!(
res.members.len(),
retrieval_limits::FAMILY_MEMBERS_MAX_LIMIT as usize
);
}
#[test]
fn default_limit_applied_when_unspecified() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let total = retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT + 5;
tester.add_n_family_members(f.id, total);
let res = query_all_family_members_paged(tester.deps(), None, None).unwrap();
assert_eq!(
res.members.len(),
retrieval_limits::FAMILY_MEMBERS_DEFAULT_LIMIT as usize
);
}
#[test]
fn excludes_node_after_it_leaves_family() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.add_to_family(f1.id, 10);
tester.add_to_family(f2.id, 11);
tester.remove_from_family(10);
let res = query_all_family_members_paged(tester.deps(), None, None).unwrap();
let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect();
assert_eq!(ids, vec![11]);
}
}
#[cfg(test)]
mod pending_invitations_for_family_paged {
use super::*;
#[test]
fn empty_when_family_has_no_pending_invitations() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let env = tester.env();
let res =
query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None)
.unwrap();
assert_eq!(res.family_id, f.id);
assert!(res.invitations.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn empty_for_unknown_family_id() {
let tester = init_contract_tester();
let env = tester.env();
let res =
query_pending_invitations_for_family_paged(tester.deps(), env, 99, None, None)
.unwrap();
assert_eq!(res.family_id, 99);
assert!(res.invitations.is_empty());
}
#[test]
fn returns_only_invitations_from_requested_family() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.invite_to_family(f1.id, 10);
tester.invite_to_family(f1.id, 11);
tester.invite_to_family(f2.id, 20);
let env = tester.env();
let res =
query_pending_invitations_for_family_paged(tester.deps(), env, f1.id, None, None)
.unwrap();
let ids: Vec<_> = res
.invitations
.iter()
.map(|d| d.invitation.node_id)
.collect();
assert_eq!(ids, vec![10, 11]);
for d in &res.invitations {
assert_eq!(d.invitation.family_id, f1.id);
}
}
#[test]
fn returned_in_ascending_node_id_order() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
// out-of-order inserts
tester.invite_to_family(f.id, 30);
tester.invite_to_family(f.id, 10);
tester.invite_to_family(f.id, 20);
let env = tester.env();
let res =
query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None)
.unwrap();
let ids: Vec<_> = res
.invitations
.iter()
.map(|d| d.invitation.node_id)
.collect();
assert_eq!(ids, vec![10, 20, 30]);
}
#[test]
fn flags_expired_against_current_block_time() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let now = tester.env().block.time.seconds();
tester.invite_to_family_with_expiration(f.id, 10, now + 5);
tester.invite_to_family_with_expiration(f.id, 11, now + 1000);
tester.advance_time_by(60);
let env = tester.env();
let res =
query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None)
.unwrap();
assert_eq!(res.invitations[0].invitation.node_id, 10);
assert!(res.invitations[0].expired);
assert_eq!(res.invitations[1].invitation.node_id, 11);
assert!(!res.invitations[1].expired);
}
#[test]
fn limit_caps_page_size_and_start_after_is_exclusive() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
for n in [10, 11, 12] {
tester.invite_to_family(f.id, n);
}
let env = tester.env();
let p1 = query_pending_invitations_for_family_paged(
tester.deps(),
env.clone(),
f.id,
None,
Some(2),
)
.unwrap();
let ids: Vec<_> = p1
.invitations
.iter()
.map(|d| d.invitation.node_id)
.collect();
assert_eq!(ids, vec![10, 11]);
assert_eq!(p1.start_next_after, Some(11));
let p2 = query_pending_invitations_for_family_paged(
tester.deps(),
env,
f.id,
p1.start_next_after,
Some(2),
)
.unwrap();
let ids: Vec<_> = p2
.invitations
.iter()
.map(|d| d.invitation.node_id)
.collect();
assert_eq!(ids, vec![12]);
}
#[test]
fn limit_is_clamped_to_max() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let total = retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT + 5;
for n in 1..=total {
tester.invite_to_family(f.id, n);
}
let env = tester.env();
let res = query_pending_invitations_for_family_paged(
tester.deps(),
env,
f.id,
None,
Some(u32::MAX),
)
.unwrap();
assert_eq!(
res.invitations.len(),
retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT as usize
);
}
#[test]
fn excludes_invitation_after_it_is_revoked() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
tester.invite_to_family(f.id, 10);
tester.invite_to_family(f.id, 11);
// accepting moves the invitation out of the pending map
tester.accept_invitation(f.id, 10);
let env = tester.env();
let res =
query_pending_invitations_for_family_paged(tester.deps(), env, f.id, None, None)
.unwrap();
let ids: Vec<_> = res
.invitations
.iter()
.map(|d| d.invitation.node_id)
.collect();
assert_eq!(ids, vec![11]);
}
}
#[cfg(test)]
mod all_pending_invitations_paged {
use super::*;
#[test]
fn empty_when_no_pending_invitations() {
let tester = init_contract_tester();
let env = tester.env();
let res = query_all_pending_invitations_paged(tester.deps(), env, None, None).unwrap();
assert!(res.invitations.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn returns_invitations_across_all_families() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.invite_to_family(f1.id, 10);
tester.invite_to_family(f1.id, 20);
tester.invite_to_family(f2.id, 5);
let env = tester.env();
let res = query_all_pending_invitations_paged(tester.deps(), env, None, None).unwrap();
let pairs: Vec<_> = res
.invitations
.iter()
.map(|d| (d.invitation.family_id, d.invitation.node_id))
.collect();
// ordered by (family_id asc, node_id asc)
assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20), (f2.id, 5)]);
assert_eq!(res.start_next_after, Some((f2.id, 5)));
}
#[test]
fn flags_expired_against_current_block_time() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let now = tester.env().block.time.seconds();
tester.invite_to_family_with_expiration(f.id, 10, now + 5);
tester.invite_to_family_with_expiration(f.id, 11, now + 1000);
tester.advance_time_by(60);
let env = tester.env();
let res = query_all_pending_invitations_paged(tester.deps(), env, None, None).unwrap();
let by_node: std::collections::HashMap<_, _> = res
.invitations
.iter()
.map(|d| (d.invitation.node_id, d.expired))
.collect();
assert!(by_node[&10]);
assert!(!by_node[&11]);
}
#[test]
fn paginates_with_composite_cursor() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.invite_to_family(f1.id, 10);
tester.invite_to_family(f1.id, 20);
tester.invite_to_family(f2.id, 5);
tester.invite_to_family(f2.id, 15);
let env = tester.env();
let p1 = query_all_pending_invitations_paged(tester.deps(), env.clone(), None, Some(2))
.unwrap();
let pairs: Vec<_> = p1
.invitations
.iter()
.map(|d| (d.invitation.family_id, d.invitation.node_id))
.collect();
assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20)]);
assert_eq!(p1.start_next_after, Some((f1.id, 20)));
let p2 = query_all_pending_invitations_paged(
tester.deps(),
env.clone(),
p1.start_next_after,
Some(2),
)
.unwrap();
let pairs: Vec<_> = p2
.invitations
.iter()
.map(|d| (d.invitation.family_id, d.invitation.node_id))
.collect();
assert_eq!(pairs, vec![(f2.id, 5), (f2.id, 15)]);
let p3 = query_all_pending_invitations_paged(
tester.deps(),
env,
p2.start_next_after,
Some(2),
)
.unwrap();
assert!(p3.invitations.is_empty());
assert!(p3.start_next_after.is_none());
}
#[test]
fn limit_is_clamped_to_max() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let total = retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT + 5;
for n in 1..=total {
tester.invite_to_family(f.id, n);
}
let env = tester.env();
let res = query_all_pending_invitations_paged(tester.deps(), env, None, Some(u32::MAX))
.unwrap();
assert_eq!(
res.invitations.len(),
retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT as usize
);
}
}
#[cfg(test)]
mod past_invitations_for_family_paged {
use super::*;
use nym_node_families_contract_common::FamilyInvitationStatus;
#[test]
fn empty_when_family_has_no_archive_entries() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let res =
query_past_invitations_for_family_paged(tester.deps(), f.id, None, None).unwrap();
assert_eq!(res.family_id, f.id);
assert!(res.invitations.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn empty_for_unknown_family_id() {
let tester = init_contract_tester();
let res =
query_past_invitations_for_family_paged(tester.deps(), 99, None, None).unwrap();
assert_eq!(res.family_id, 99);
assert!(res.invitations.is_empty());
}
#[test]
fn returns_only_archived_invitations_from_requested_family() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
// produce one Accepted in each family, plus one Rejected in f1
tester.add_to_family(f1.id, 10);
tester.invite_to_family(f1.id, 11);
tester.reject_invitation(f1.id, 11);
tester.add_to_family(f2.id, 20);
let res =
query_past_invitations_for_family_paged(tester.deps(), f1.id, None, None).unwrap();
assert_eq!(res.invitations.len(), 2);
for entry in &res.invitations {
assert_eq!(entry.invitation.family_id, f1.id);
}
}
#[test]
fn covers_all_terminal_statuses() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
// Accepted
tester.add_to_family(f.id, 10);
// Rejected
tester.invite_to_family(f.id, 11);
tester.reject_invitation(f.id, 11);
// Revoked
tester.invite_to_family(f.id, 12);
tester.revoke_invitation(f.id, 12);
let res =
query_past_invitations_for_family_paged(tester.deps(), f.id, None, None).unwrap();
let by_node: std::collections::HashMap<_, _> = res
.invitations
.iter()
.map(|p| (p.invitation.node_id, p.status.clone()))
.collect();
assert!(matches!(
by_node[&10],
FamilyInvitationStatus::Accepted { .. }
));
assert!(matches!(
by_node[&11],
FamilyInvitationStatus::Rejected { .. }
));
assert!(matches!(
by_node[&12],
FamilyInvitationStatus::Revoked { .. }
));
}
#[test]
fn ordered_by_node_id_then_counter() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
// node 42 joins and leaves twice — produces two Accepted entries with counters 0 and 1
for _ in 0..2 {
tester.add_to_family(f.id, 42);
tester.remove_from_family(42);
}
// node 7 has one Accepted entry
tester.add_to_family(f.id, 7);
let res =
query_past_invitations_for_family_paged(tester.deps(), f.id, None, None).unwrap();
let pairs: Vec<_> = res
.invitations
.iter()
.map(|p| p.invitation.node_id)
.collect();
// 7 comes before 42; 42's two entries come together (both with counter 0 and 1)
assert_eq!(pairs, vec![7, 42, 42]);
}
#[test]
fn paginates_with_node_counter_cursor() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
for n in [10, 11, 12] {
tester.add_to_family(f.id, n);
}
let p1 = query_past_invitations_for_family_paged(tester.deps(), f.id, None, Some(2))
.unwrap();
let ids: Vec<_> = p1
.invitations
.iter()
.map(|p| p.invitation.node_id)
.collect();
assert_eq!(ids, vec![10, 11]);
assert_eq!(p1.start_next_after, Some((11, 0)));
let p2 = query_past_invitations_for_family_paged(
tester.deps(),
f.id,
p1.start_next_after,
Some(2),
)
.unwrap();
let ids: Vec<_> = p2
.invitations
.iter()
.map(|p| p.invitation.node_id)
.collect();
assert_eq!(ids, vec![12]);
assert_eq!(p2.start_next_after, Some((12, 0)));
let p3 = query_past_invitations_for_family_paged(
tester.deps(),
f.id,
p2.start_next_after,
Some(2),
)
.unwrap();
assert!(p3.invitations.is_empty());
assert!(p3.start_next_after.is_none());
}
#[test]
fn limit_is_clamped_to_max() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let total = retrieval_limits::PAST_INVITATIONS_MAX_LIMIT + 5;
tester.add_n_family_members(f.id, total);
let res =
query_past_invitations_for_family_paged(tester.deps(), f.id, None, Some(u32::MAX))
.unwrap();
assert_eq!(
res.invitations.len(),
retrieval_limits::PAST_INVITATIONS_MAX_LIMIT as usize
);
}
#[test]
fn start_after_none_does_not_leak_earlier_families() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.add_to_family(f1.id, 10);
tester.add_to_family(f1.id, 11);
let res =
query_past_invitations_for_family_paged(tester.deps(), f2.id, None, None).unwrap();
assert_eq!(res.family_id, f2.id);
assert!(
res.invitations.is_empty(),
"expected no entries for f2 but got: {:?}",
res.invitations
.iter()
.map(|e| (e.invitation.family_id, e.invitation.node_id))
.collect::<Vec<_>>()
);
assert!(res.start_next_after.is_none());
}
}
#[cfg(test)]
mod all_past_invitations_paged {
use super::*;
#[test]
fn empty_when_no_archive_entries() {
let tester = init_contract_tester();
let res = query_all_past_invitations_paged(tester.deps(), None, None).unwrap();
assert!(res.invitations.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn returns_archives_across_all_families() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.add_to_family(f1.id, 10);
tester.add_to_family(f1.id, 20);
tester.add_to_family(f2.id, 5);
let res = query_all_past_invitations_paged(tester.deps(), None, None).unwrap();
let pairs: Vec<_> = res
.invitations
.iter()
.map(|p| (p.invitation.family_id, p.invitation.node_id))
.collect();
assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20), (f2.id, 5)]);
assert_eq!(res.start_next_after, Some(((f2.id, 5), 0)));
}
#[test]
fn paginates_with_composite_cursor() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.add_to_family(f1.id, 10);
tester.add_to_family(f1.id, 20);
tester.add_to_family(f2.id, 5);
tester.add_to_family(f2.id, 15);
let p1 = query_all_past_invitations_paged(tester.deps(), None, Some(2)).unwrap();
let pairs: Vec<_> = p1
.invitations
.iter()
.map(|p| (p.invitation.family_id, p.invitation.node_id))
.collect();
assert_eq!(pairs, vec![(f1.id, 10), (f1.id, 20)]);
assert_eq!(p1.start_next_after, Some(((f1.id, 20), 0)));
let p2 = query_all_past_invitations_paged(tester.deps(), p1.start_next_after, Some(2))
.unwrap();
let pairs: Vec<_> = p2
.invitations
.iter()
.map(|p| (p.invitation.family_id, p.invitation.node_id))
.collect();
assert_eq!(pairs, vec![(f2.id, 5), (f2.id, 15)]);
let p3 = query_all_past_invitations_paged(tester.deps(), p2.start_next_after, Some(2))
.unwrap();
assert!(p3.invitations.is_empty());
assert!(p3.start_next_after.is_none());
}
#[test]
fn per_pair_counter_disambiguates_repeat_archive_entries() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
// node 42 joins and leaves twice — two Accepted entries for (f, 42) with counters 0 and 1
for _ in 0..2 {
tester.add_to_family(f.id, 42);
tester.remove_from_family(42);
}
let res = query_all_past_invitations_paged(tester.deps(), None, None).unwrap();
assert_eq!(res.invitations.len(), 2);
assert_eq!(res.start_next_after, Some(((f.id, 42), 1)));
}
#[test]
fn limit_is_clamped_to_max() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let total = retrieval_limits::PAST_INVITATIONS_MAX_LIMIT + 5;
tester.add_n_family_members(f.id, total);
let res =
query_all_past_invitations_paged(tester.deps(), None, Some(u32::MAX)).unwrap();
assert_eq!(
res.invitations.len(),
retrieval_limits::PAST_INVITATIONS_MAX_LIMIT as usize
);
}
}
#[cfg(test)]
mod pending_invitations_for_node_paged {
use super::*;
#[test]
fn empty_when_node_has_no_pending_invitations() {
let tester = init_contract_tester();
let env = tester.env();
let res = query_pending_invitations_for_node_paged(tester.deps(), env, 42, None, None)
.unwrap();
assert_eq!(res.node_id, 42);
assert!(res.invitations.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn returns_only_invitations_for_requested_node() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.invite_to_family(f1.id, 10);
tester.invite_to_family(f1.id, 11);
tester.invite_to_family(f2.id, 10);
let env = tester.env();
let res = query_pending_invitations_for_node_paged(tester.deps(), env, 10, None, None)
.unwrap();
let pairs: Vec<_> = res
.invitations
.iter()
.map(|d| (d.invitation.family_id, d.invitation.node_id))
.collect();
assert_eq!(pairs, vec![(f1.id, 10), (f2.id, 10)]);
}
#[test]
fn ordered_by_ascending_family_id() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let f3 = tester.add_dummy_family();
// out-of-order inserts
tester.invite_to_family(f3.id, 7);
tester.invite_to_family(f1.id, 7);
tester.invite_to_family(f2.id, 7);
let env = tester.env();
let res = query_pending_invitations_for_node_paged(tester.deps(), env, 7, None, None)
.unwrap();
let ids: Vec<_> = res
.invitations
.iter()
.map(|d| d.invitation.family_id)
.collect();
assert_eq!(ids, vec![f1.id, f2.id, f3.id]);
}
#[test]
fn paginates_with_family_id_cursor() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let f3 = tester.add_dummy_family();
tester.invite_to_family(f1.id, 7);
tester.invite_to_family(f2.id, 7);
tester.invite_to_family(f3.id, 7);
let env = tester.env();
let p1 = query_pending_invitations_for_node_paged(
tester.deps(),
env.clone(),
7,
None,
Some(2),
)
.unwrap();
let ids: Vec<_> = p1
.invitations
.iter()
.map(|d| d.invitation.family_id)
.collect();
assert_eq!(ids, vec![f1.id, f2.id]);
assert_eq!(p1.start_next_after, Some(f2.id));
let p2 = query_pending_invitations_for_node_paged(
tester.deps(),
env.clone(),
7,
p1.start_next_after,
Some(2),
)
.unwrap();
let ids: Vec<_> = p2
.invitations
.iter()
.map(|d| d.invitation.family_id)
.collect();
assert_eq!(ids, vec![f3.id]);
assert_eq!(p2.start_next_after, Some(f3.id));
let p3 = query_pending_invitations_for_node_paged(
tester.deps(),
env,
7,
p2.start_next_after,
Some(2),
)
.unwrap();
assert!(p3.invitations.is_empty());
assert!(p3.start_next_after.is_none());
}
#[test]
fn flags_expired_against_current_block_time() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let now = tester.env().block.time.seconds();
tester.invite_to_family_with_expiration(f1.id, 7, now + 5);
tester.invite_to_family_with_expiration(f2.id, 7, now + 1000);
tester.advance_time_by(60);
let env = tester.env();
let res = query_pending_invitations_for_node_paged(tester.deps(), env, 7, None, None)
.unwrap();
let by_family: std::collections::HashMap<_, _> = res
.invitations
.iter()
.map(|d| (d.invitation.family_id, d.expired))
.collect();
assert!(by_family[&f1.id]);
assert!(!by_family[&f2.id]);
}
#[test]
fn limit_is_clamped_to_max() {
let mut tester = init_contract_tester();
let total = retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT + 5;
for _ in 0..total {
let f = tester.add_dummy_family();
tester.invite_to_family(f.id, 7);
}
let env = tester.env();
let res = query_pending_invitations_for_node_paged(
tester.deps(),
env,
7,
None,
Some(u32::MAX),
)
.unwrap();
assert_eq!(
res.invitations.len(),
retrieval_limits::PENDING_INVITATIONS_MAX_LIMIT as usize
);
}
}
#[cfg(test)]
mod past_invitations_for_node_paged {
use super::*;
use nym_node_families_contract_common::FamilyInvitationStatus;
#[test]
fn empty_when_node_has_no_archive_entries() {
let tester = init_contract_tester();
let res = query_past_invitations_for_node_paged(tester.deps(), 42, None, None).unwrap();
assert_eq!(res.node_id, 42);
assert!(res.invitations.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn returns_only_archives_for_requested_node() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.add_to_family(f1.id, 7);
tester.add_to_family(f1.id, 8);
tester.add_to_family(f2.id, 7);
let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, None).unwrap();
assert_eq!(res.invitations.len(), 2);
for entry in &res.invitations {
assert_eq!(entry.invitation.node_id, 7);
}
}
#[test]
fn covers_all_terminal_statuses() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let f3 = tester.add_dummy_family();
// Accepted in f1
tester.add_to_family(f1.id, 7);
// Rejected in f2
tester.invite_to_family(f2.id, 7);
tester.reject_invitation(f2.id, 7);
// Revoked in f3
tester.invite_to_family(f3.id, 7);
tester.revoke_invitation(f3.id, 7);
let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, None).unwrap();
let by_family: std::collections::HashMap<_, _> = res
.invitations
.iter()
.map(|p| (p.invitation.family_id, p.status.clone()))
.collect();
assert!(matches!(
by_family[&f1.id],
FamilyInvitationStatus::Accepted { .. }
));
assert!(matches!(
by_family[&f2.id],
FamilyInvitationStatus::Rejected { .. }
));
assert!(matches!(
by_family[&f3.id],
FamilyInvitationStatus::Revoked { .. }
));
}
#[test]
fn ordered_by_family_id_then_counter() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
// node 7 joins and leaves f1 twice — counters 0 and 1 in f1
for _ in 0..2 {
tester.add_to_family(f1.id, 7);
tester.remove_from_family(7);
}
// one Accepted in f2
tester.add_to_family(f2.id, 7);
let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, None).unwrap();
let pairs: Vec<_> = res
.invitations
.iter()
.map(|p| p.invitation.family_id)
.collect();
assert_eq!(pairs, vec![f1.id, f1.id, f2.id]);
}
#[test]
fn paginates_with_family_counter_cursor() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let f3 = tester.add_dummy_family();
tester.add_to_family(f1.id, 7);
tester.add_to_family(f2.id, 7);
tester.add_to_family(f3.id, 7);
let p1 =
query_past_invitations_for_node_paged(tester.deps(), 7, None, Some(2)).unwrap();
let ids: Vec<_> = p1
.invitations
.iter()
.map(|p| p.invitation.family_id)
.collect();
assert_eq!(ids, vec![f1.id, f2.id]);
assert_eq!(p1.start_next_after, Some((f2.id, 0)));
let p2 = query_past_invitations_for_node_paged(
tester.deps(),
7,
p1.start_next_after,
Some(2),
)
.unwrap();
let ids: Vec<_> = p2
.invitations
.iter()
.map(|p| p.invitation.family_id)
.collect();
assert_eq!(ids, vec![f3.id]);
assert_eq!(p2.start_next_after, Some((f3.id, 0)));
let p3 = query_past_invitations_for_node_paged(
tester.deps(),
7,
p2.start_next_after,
Some(2),
)
.unwrap();
assert!(p3.invitations.is_empty());
assert!(p3.start_next_after.is_none());
}
#[test]
fn limit_is_clamped_to_max() {
let mut tester = init_contract_tester();
let total = retrieval_limits::PAST_INVITATIONS_MAX_LIMIT + 5;
for _ in 0..total {
let f = tester.add_dummy_family();
tester.add_to_family(f.id, 7);
tester.remove_from_family(7);
}
let res = query_past_invitations_for_node_paged(tester.deps(), 7, None, Some(u32::MAX))
.unwrap();
assert_eq!(
res.invitations.len(),
retrieval_limits::PAST_INVITATIONS_MAX_LIMIT as usize
);
}
}
#[cfg(test)]
mod past_members_for_family_paged {
use super::*;
#[test]
fn empty_when_no_archive_entries() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let res = query_past_members_for_family_paged(tester.deps(), f.id, None, None).unwrap();
assert_eq!(res.family_id, f.id);
assert!(res.members.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn empty_for_unknown_family_id() {
let tester = init_contract_tester();
let res = query_past_members_for_family_paged(tester.deps(), 99, None, None).unwrap();
assert_eq!(res.family_id, 99);
assert!(res.members.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn returns_only_archives_from_requested_family() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.add_to_family(f1.id, 10);
tester.remove_from_family(10);
tester.add_to_family(f2.id, 20);
tester.remove_from_family(20);
let res =
query_past_members_for_family_paged(tester.deps(), f1.id, None, None).unwrap();
assert_eq!(res.members.len(), 1);
assert_eq!(res.members[0].family_id, f1.id);
assert_eq!(res.members[0].node_id, 10);
}
#[test]
fn record_carries_removed_at_timestamp() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
tester.add_to_family(f.id, 42);
let removed_at = tester.env().block.time.seconds();
tester.remove_from_family(42);
let res = query_past_members_for_family_paged(tester.deps(), f.id, None, None).unwrap();
let entry = res.members.into_iter().next().unwrap();
assert_eq!(entry.family_id, f.id);
assert_eq!(entry.node_id, 42);
assert_eq!(entry.removed_at, removed_at);
}
#[test]
fn ordered_by_node_id_then_counter() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
// node 42 joins/leaves twice — counters 0 and 1
for _ in 0..2 {
tester.add_to_family(f.id, 42);
tester.remove_from_family(42);
}
// node 7 once
tester.add_to_family(f.id, 7);
tester.remove_from_family(7);
let res = query_past_members_for_family_paged(tester.deps(), f.id, None, None).unwrap();
let ids: Vec<_> = res.members.iter().map(|m| m.node_id).collect();
// 7 comes before 42; 42 appears twice
assert_eq!(ids, vec![7, 42, 42]);
}
#[test]
fn paginates_with_node_counter_cursor() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
for n in [10, 11, 12] {
tester.add_to_family(f.id, n);
tester.remove_from_family(n);
}
let p1 =
query_past_members_for_family_paged(tester.deps(), f.id, None, Some(2)).unwrap();
let ids: Vec<_> = p1.members.iter().map(|m| m.node_id).collect();
assert_eq!(ids, vec![10, 11]);
assert_eq!(p1.start_next_after, Some((11, 0)));
let p2 = query_past_members_for_family_paged(
tester.deps(),
f.id,
p1.start_next_after,
Some(2),
)
.unwrap();
let ids: Vec<_> = p2.members.iter().map(|m| m.node_id).collect();
assert_eq!(ids, vec![12]);
assert_eq!(p2.start_next_after, Some((12, 0)));
let p3 = query_past_members_for_family_paged(
tester.deps(),
f.id,
p2.start_next_after,
Some(2),
)
.unwrap();
assert!(p3.members.is_empty());
assert!(p3.start_next_after.is_none());
}
#[test]
fn limit_is_clamped_to_max() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
let total = retrieval_limits::PAST_MEMBERS_MAX_LIMIT + 5;
for n in 1..=total {
tester.add_to_family(f.id, n);
tester.remove_from_family(n);
}
let res =
query_past_members_for_family_paged(tester.deps(), f.id, None, Some(u32::MAX))
.unwrap();
assert_eq!(
res.members.len(),
retrieval_limits::PAST_MEMBERS_MAX_LIMIT as usize
);
}
#[test]
fn start_after_none_does_not_leak_earlier_families() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.add_to_family(f1.id, 10);
tester.add_to_family(f1.id, 11);
tester.remove_from_family(10);
tester.remove_from_family(11);
let res =
query_past_members_for_family_paged(tester.deps(), f2.id, None, None).unwrap();
assert_eq!(res.family_id, f2.id);
assert!(
res.members.is_empty(),
"expected no entries for f2 but got: {:?}",
res.members
.iter()
.map(|m| (m.family_id, m.node_id))
.collect::<Vec<_>>()
);
assert!(res.start_next_after.is_none());
}
}
#[cfg(test)]
mod past_members_for_node_paged {
use super::*;
#[test]
fn empty_when_node_has_no_archive_entries() {
let tester = init_contract_tester();
let res = query_past_members_for_node_paged(tester.deps(), 42, None, None).unwrap();
assert_eq!(res.node_id, 42);
assert!(res.members.is_empty());
assert!(res.start_next_after.is_none());
}
#[test]
fn returns_only_archives_for_requested_node() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
tester.add_to_family(f1.id, 7);
tester.add_to_family(f1.id, 8);
tester.add_to_family(f2.id, 7);
tester.remove_from_family(7);
tester.remove_from_family(8);
let res = query_past_members_for_node_paged(tester.deps(), 7, None, None).unwrap();
assert_eq!(res.members.len(), 1);
for entry in &res.members {
assert_eq!(entry.node_id, 7);
}
}
#[test]
fn record_carries_removed_at_timestamp() {
let mut tester = init_contract_tester();
let f = tester.add_dummy_family();
tester.add_to_family(f.id, 42);
let removed_at = tester.env().block.time.seconds();
tester.remove_from_family(42);
let res = query_past_members_for_node_paged(tester.deps(), 42, None, None).unwrap();
let entry = res.members.into_iter().next().unwrap();
assert_eq!(entry.family_id, f.id);
assert_eq!(entry.node_id, 42);
assert_eq!(entry.removed_at, removed_at);
}
#[test]
fn ordered_by_family_id_then_counter() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
// node 7 joins/leaves f1 twice — counters 0 and 1 in f1
for _ in 0..2 {
tester.add_to_family(f1.id, 7);
tester.remove_from_family(7);
}
// one record in f2
tester.add_to_family(f2.id, 7);
tester.remove_from_family(7);
let res = query_past_members_for_node_paged(tester.deps(), 7, None, None).unwrap();
let ids: Vec<_> = res.members.iter().map(|m| m.family_id).collect();
assert_eq!(ids, vec![f1.id, f1.id, f2.id]);
}
#[test]
fn paginates_with_family_counter_cursor() {
let mut tester = init_contract_tester();
let f1 = tester.add_dummy_family();
let f2 = tester.add_dummy_family();
let f3 = tester.add_dummy_family();
tester.add_to_family(f1.id, 7);
tester.remove_from_family(7);
tester.add_to_family(f2.id, 7);
tester.remove_from_family(7);
tester.add_to_family(f3.id, 7);
tester.remove_from_family(7);
let p1 = query_past_members_for_node_paged(tester.deps(), 7, None, Some(2)).unwrap();
let ids: Vec<_> = p1.members.iter().map(|m| m.family_id).collect();
assert_eq!(ids, vec![f1.id, f2.id]);
assert_eq!(p1.start_next_after, Some((f2.id, 0)));
let p2 =
query_past_members_for_node_paged(tester.deps(), 7, p1.start_next_after, Some(2))
.unwrap();
let ids: Vec<_> = p2.members.iter().map(|m| m.family_id).collect();
assert_eq!(ids, vec![f3.id]);
assert_eq!(p2.start_next_after, Some((f3.id, 0)));
let p3 =
query_past_members_for_node_paged(tester.deps(), 7, p2.start_next_after, Some(2))
.unwrap();
assert!(p3.members.is_empty());
assert!(p3.start_next_after.is_none());
}
#[test]
fn limit_is_clamped_to_max() {
let mut tester = init_contract_tester();
let total = retrieval_limits::PAST_MEMBERS_MAX_LIMIT + 5;
for _ in 0..total {
let f = tester.add_dummy_family();
tester.add_to_family(f.id, 7);
tester.remove_from_family(7);
}
let res =
query_past_members_for_node_paged(tester.deps(), 7, None, Some(u32::MAX)).unwrap();
assert_eq!(
res.members.len(),
retrieval_limits::PAST_MEMBERS_MAX_LIMIT as usize
);
}
}
}