Newer
Older
// This file is part of Duniter-v2S.
// Duniter-v2S is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, version 3 of the License.
//
// Duniter-v2S is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Duniter-v2S. If not, see <https://www.gnu.org/licenses/>.
use cucumber::{given, then, when, StatsWriter, World};
use std::{
path::PathBuf,
str::FromStr,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
use subxt::backend::rpc::RpcClient;
#[derive(cucumber::World, Default)]
pub struct DuniterWorld {
ignore_errors: bool,
inner: Option<DuniterWorldInner>,
}
impl DuniterWorld {
async fn init(&mut self, maybe_genesis_conf_file: Option<PathBuf>, no_spawn: bool) {
if let Some(ref mut inner) = self.inner {
inner.kill();
}
self.inner = Some(DuniterWorldInner::new(maybe_genesis_conf_file, no_spawn).await);
fn kill(&mut self) {
if let Some(ref mut inner) = self.inner {
inner.kill();
}
}
fn set_ignore_errors(&mut self, ignore_errors: bool) {
self.ignore_errors = ignore_errors;
}
fn rpc_client(&self) -> &RpcClient {
if let Some(ref inner) = self.inner {
&inner.client.rpc
} else {
panic!("uninit")
}
}
fn client(&self) -> &Client {
if let Some(ref inner) = self.inner {
&inner.client.client
} else {
panic!("uninit")
}
}
fn full_client(&self) -> &FullClient {
if let Some(ref inner) = self.inner {
&inner.client
} else {
panic!("uninit")
}
}
// Read methods
fn ignore_errors(&self) -> bool {
self.ignore_errors
async fn read<'a, Address>(
&self,
address: &'a Address,
) -> impl std::future::Future<
Output = std::result::Result<Option<Address::Target>, subxt::error::Error>,
Address: subxt::storage::Address<IsFetchable = subxt::custom_values::Yes> + 'a,
self.client()
.storage()
.at_latest()
.await
.unwrap()
.fetch(address)
// Read storage entry with default value (on last block)
async fn read_or_default<'a, Address>(
) -> impl std::future::Future<Output = std::result::Result<Address::Target, subxt::error::Error>> + 'a
Address: subxt::storage::Address<
IsFetchable = subxt::custom_values::Yes,
IsDefaultable = subxt::custom_values::Yes,
self.client()
.storage()
.at_latest()
.await
.unwrap()
.fetch_or_default(address)
}
impl std::fmt::Debug for DuniterWorld {
fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
Ok(())
}
}
struct DuniterWorldInner {
client: FullClient,
process: Option<Process>,
}
impl DuniterWorldInner {
async fn new(maybe_genesis_conf_file: Option<PathBuf>, no_spawn: bool) -> Self {
let (client, process, ws_port) = spawn_node(maybe_genesis_conf_file, no_spawn).await;
DuniterWorldInner {
client,
process,
ws_port,
}
fn kill(&mut self) {
if let Some(p) = &mut self.process {
p.kill();
}
fn parse_amount(amount: u64, unit: &str) -> (u64, bool) {
match unit {
"ĞD" => (amount * 100, false),
"cĞD" => (amount, false),
"UD" => (amount * 1_000, true),
"mUD" => (amount, true),
_ => unreachable!(),
}
#[allow(clippy::needless_pass_by_ref_mut)]
#[given(regex = r"([a-zA-Z]+) ha(?:ve|s) (\d+) (ĞD|cĞD|UD|mUD)")]
async fn who_have(world: &mut DuniterWorld, who: String, amount: u64, unit: String) -> Result<()> {
let who = AccountKeyring::from_str(&who).expect("unknown to");
let (mut amount, is_ud) = parse_amount(amount, &unit);
if is_ud {
let current_ud_amount = world
.read(&gdev::storage().universal_dividend().current_ud())
common::balances::set_balance(world.full_client(), who, amount).await?;
#[allow(clippy::needless_pass_by_ref_mut)]
#[when(regex = r"(\d+) blocks? later")]
async fn n_blocks_later(world: &mut DuniterWorld, n: usize) -> Result<()> {
for _ in 0..n {
common::create_empty_block(world.rpc_client()).await?;
#[allow(clippy::needless_pass_by_ref_mut)]
#[when(regex = r"([a-zA-Z]+) sends? (\d+) (ĞD|cĞD|UD|mUD) to ([a-zA-Z]+)$")]
async fn transfer(
world: &mut DuniterWorld,
from: String,
amount: u64,
unit: String,
to: String,
) -> Result<()> {
// Parse inputs
let from = AccountKeyring::from_str(&from).expect("unknown from");
let to = AccountKeyring::from_str(&to).expect("unknown to");
let res = if is_ud {
common::balances::transfer_ud(world.full_client(), from, amount, to).await
common::balances::transfer(world.full_client(), from, amount, to).await
};
if world.ignore_errors() {
Ok(())
} else {
res
#[allow(clippy::needless_pass_by_ref_mut)]
#[when(regex = r"([a-zA-Z]+) sends? (\d+) (ĞD|cĞD) to oneshot ([a-zA-Z]+)")]
async fn create_oneshot_account(
world: &mut DuniterWorld,
from: String,
amount: u64,
unit: String,
to: String,
) -> Result<()> {
// Parse inputs
let from = AccountKeyring::from_str(&from).expect("unknown from");
let to = AccountKeyring::from_str(&to).expect("unknown to");
let (amount, is_ud) = parse_amount(amount, &unit);
assert!(!is_ud);
common::oneshot::create_oneshot_account(world.full_client(), from, amount, to).await
#[allow(clippy::needless_pass_by_ref_mut)]
#[when(regex = r"oneshot ([a-zA-Z]+) consumes? into (oneshot|account) ([a-zA-Z]+)")]
async fn consume_oneshot_account(
world: &mut DuniterWorld,
from: String,
is_dest_oneshot: String,
to: String,
) -> Result<()> {
// Parse inputs
let from = AccountKeyring::from_str(&from).expect("unknown from");
let to = AccountKeyring::from_str(&to).expect("unknown to");
let to = match is_dest_oneshot.as_str() {
"oneshot" => common::oneshot::Account::Oneshot(to),
"account" => common::oneshot::Account::Normal(to),
_ => unreachable!(),
};
common::oneshot::consume_oneshot_account(world.full_client(), from, to).await
}
#[when(
regex = r"oneshot ([a-zA-Z]+) consumes? (\d+) (ĞD|cĞD) into (oneshot|account) ([a-zA-Z]+) and the rest into (oneshot|account) ([a-zA-Z]+)"
)]
#[allow(clippy::too_many_arguments)]
#[allow(clippy::needless_pass_by_ref_mut)]
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
async fn consume_oneshot_account_with_remaining(
world: &mut DuniterWorld,
from: String,
amount: u64,
unit: String,
is_dest_oneshot: String,
to: String,
is_remaining_to_oneshot: String,
remaining_to: String,
) -> Result<()> {
// Parse inputs
let from = AccountKeyring::from_str(&from).expect("unknown from");
let to = AccountKeyring::from_str(&to).expect("unknown to");
let remaining_to = AccountKeyring::from_str(&remaining_to).expect("unknown remaining_to");
let to = match is_dest_oneshot.as_str() {
"oneshot" => common::oneshot::Account::Oneshot(to),
"account" => common::oneshot::Account::Normal(to),
_ => unreachable!(),
};
let remaining_to = match is_remaining_to_oneshot.as_str() {
"oneshot" => common::oneshot::Account::Oneshot(remaining_to),
"account" => common::oneshot::Account::Normal(remaining_to),
_ => unreachable!(),
};
let (amount, is_ud) = parse_amount(amount, &unit);
assert!(!is_ud);
common::oneshot::consume_oneshot_account_with_remaining(
world.full_client(),
from,
amount,
to,
remaining_to,
)
.await
}
#[allow(clippy::needless_pass_by_ref_mut)]
#[when(regex = r"([a-zA-Z]+) sends? all (?:his|her) (?:ĞDs?|DUs?|UDs?) to ([a-zA-Z]+)")]
async fn send_all_to(world: &mut DuniterWorld, from: String, to: String) -> Result<()> {
// Parse inputs
let from = AccountKeyring::from_str(&from).expect("unknown from");
let to = AccountKeyring::from_str(&to).expect("unknown to");
common::balances::transfer_all(world.full_client(), from, to).await
#[allow(clippy::needless_pass_by_ref_mut)]
#[when(regex = r"([a-zA-Z]+) certifies ([a-zA-Z]+)")]
async fn certifies(world: &mut DuniterWorld, from: String, to: String) -> Result<()> {
// Parse inputs
let from = AccountKeyring::from_str(&from).expect("unknown from");
let to = AccountKeyring::from_str(&to).expect("unknown to");
common::cert::certify(world.full_client(), from, to).await
#[allow(clippy::needless_pass_by_ref_mut)]
#[when(regex = r"([a-zA-Z]+) creates identity for ([a-zA-Z]+)")]
async fn creates_identity(world: &mut DuniterWorld, from: String, to: String) -> Result<()> {
// Parse inputs
let from = AccountKeyring::from_str(&from).expect("unknown from");
let to = AccountKeyring::from_str(&to).expect("unknown to");
common::identity::create_identity(world.full_client(), from, to).await
#[allow(clippy::needless_pass_by_ref_mut)]
#[when(regex = r#"([a-zA-Z]+) confirms (?:his|her) identity with pseudo "([a-zA-Z]+)""#)]
async fn confirm_identity(world: &mut DuniterWorld, from: String, pseudo: String) -> Result<()> {
let from = AccountKeyring::from_str(&from).expect("unknown from");
common::identity::confirm_identity(world.full_client(), from, pseudo).await
#[allow(clippy::needless_pass_by_ref_mut)]
#[when(regex = r#"([a-zA-Z]+) requests distance evaluation"#)]
async fn request_distance_evaluation(world: &mut DuniterWorld, who: String) -> Result<()> {
let who = AccountKeyring::from_str(&who).expect("unknown origin");
common::distance::request_evaluation(world.full_client(), who).await
#[allow(clippy::needless_pass_by_ref_mut)]
#[when(regex = r#"([a-zA-Z]+) runs distance oracle"#)]
async fn run_distance_oracle(world: &mut DuniterWorld, who: String) -> Result<()> {
let who = AccountKeyring::from_str(&who).expect("unknown origin");
common::distance::run_oracle(
world.full_client(),
who,
format!("ws://127.0.0.1:{}", world.inner.as_ref().unwrap().ws_port),
)
.await
}
#[allow(clippy::needless_pass_by_ref_mut)]
#[then(regex = r"treasury should contain (\d+) (ĞD|cĞD)")]
async fn treasury_should_contain(
world: &mut DuniterWorld,
amount: u64,
unit: String,
) -> Result<()> {
let who =
subxt::utils::AccountId32::from_str("5EYCAe5ijiYfyeZ2JJCGq56LmPyNRAKzpG4QkoQkkQNB5e6Z")
.expect("invalid treasury account id");
let (amount, _is_ud) = parse_amount(amount, &unit);
let who_account = world
.read_or_default(&gdev::storage().system().account(&who))
.await
.await?;
assert_eq!(who_account.data.free, amount);
Ok(())
}
#[allow(clippy::needless_pass_by_ref_mut)]
#[then(regex = r"([a-zA-Z]+) should have (\d+) (ĞD|cĞD)( reserved)?")]
async fn should_have(
world: &mut DuniterWorld,
who: String,
amount: u64,
unit: String,
reserved: String,
) -> Result<()> {
let who: subxt::utils::AccountId32 = AccountKeyring::from_str(&who)
.to_account_id()
.into();
let (amount, _is_ud) = parse_amount(amount, &unit);
let who_account = world
.read_or_default(&gdev::storage().system().account(&who))
if reserved.is_empty() {
assert_eq!(who_account.data.free, amount);
} else {
assert_eq!(who_account.data.reserved, amount);
}
#[allow(clippy::needless_pass_by_ref_mut)]
#[then(regex = r"([a-zA-Z]+) should have oneshot (\d+) (ĞD|cĞD)")]
async fn should_have_oneshot(
world: &mut DuniterWorld,
who: String,
amount: u64,
unit: String,
) -> Result<()> {
// Parse inputs
let who: subxt::utils::AccountId32 = AccountKeyring::from_str(&who)
.expect("unknown to")
.to_account_id()
.into();
let (amount, _is_ud) = parse_amount(amount, &unit);
let oneshot_amount = world
.read(&gdev::storage().oneshot_account().oneshot_accounts(&who))
.await?;
assert_eq!(oneshot_amount.unwrap_or(0), amount);
Ok(())
}
#[allow(clippy::needless_pass_by_ref_mut)]
#[then(regex = r"Current UD amount should be (\d+).(\d+)")]
async fn current_ud_amount_should_be(
world: &mut DuniterWorld,
amount: u64,
cents: u64,
) -> Result<()> {
let expected = (amount * 100) + cents;
let actual = world
.read_or_default(&gdev::storage().universal_dividend().current_ud())
assert_eq!(actual, expected);
Ok(())
}
#[allow(clippy::needless_pass_by_ref_mut)]
#[then(regex = r"Monetary mass should be (\d+).(\d+)")]
async fn monetary_mass_should_be(world: &mut DuniterWorld, amount: u64, cents: u64) -> Result<()> {
let expected = (amount * 100) + cents;
let actual = world
.read_or_default(&gdev::storage().universal_dividend().monetary_mass())
.await?;
assert_eq!(actual, expected);
#[allow(clippy::needless_pass_by_ref_mut)]
#[then(regex = r"([a-zA-Z]+) should be certified by ([a-zA-Z]+)")]
async fn should_be_certified_by(
world: &mut DuniterWorld,
receiver: String,
issuer: String,
) -> Result<()> {
// Parse inputs
let receiver_account: subxt::utils::AccountId32 = AccountKeyring::from_str(&receiver)
.to_account_id()
.into();
let issuer_account: subxt::utils::AccountId32 = AccountKeyring::from_str(&issuer)
.to_account_id()
.into();
// get corresponding identities index
.read(
&gdev::storage()
.identity()
.identity_index_of(&issuer_account),
)
.await?
.unwrap();
let receiver_index = world
.read(
&gdev::storage()
.identity()
.identity_index_of(&receiver_account),
)
let issuers = world
.read_or_default(
&gdev::storage()
.certification()
.certs_by_receiver(receiver_index),
)
// look for certification by issuer/receiver pair
match issuers.binary_search_by(|(issuer_, _)| issuer_.cmp(&issuer_index)) {
Ok(_) => Ok(()),
Err(_) => Err(anyhow::anyhow!(
"no certification found from {} ({issuer_index}) to {} ({receiver_index}): {:?}",
issuer,
receiver,
issuers
)
.into()),
}
#[then(regex = r"([a-zA-Z]+) should (not )?be eligible to UD")]
async fn should_be_eligible_to_ud(
world: &mut DuniterWorld,
identity: String,
not: String,
) -> Result<()> {
let eligible = not.is_empty();
assert_eq!(
identity::get_identity_value(world, identity)
.await
.expect("Identity not found")
.data
.first_eligible_ud
!= 0,
eligible
);
Ok(())
}
use gdev::runtime_types::pallet_identity::types::IdtyStatus;
// status from string
impl FromStr for IdtyStatus {
type Err = String;
fn from_str(input: &str) -> std::result::Result<IdtyStatus, String> {
match input {
"unconfirmed" => Ok(IdtyStatus::Unconfirmed),
"unvalidated" => Ok(IdtyStatus::Unvalidated),
"member" => Ok(IdtyStatus::Member),
"notmember" => Ok(IdtyStatus::NotMember),
"revoked" => Ok(IdtyStatus::Revoked),
_ => Err(format!("'{input}' does not match a status")),
#[allow(clippy::needless_pass_by_ref_mut)]
#[then(regex = r"([a-zA-Z]+) identity should be ([a-zA-Z ]+)")]
async fn identity_status_should_be(
world: &mut DuniterWorld,
name: String,
status: String,
) -> Result<()> {
let identity_value = common::identity::get_identity_value(world, name).await?;
let expected_status = IdtyStatus::from_str(&status)?;
assert_eq!(identity_value.status, expected_status);
Ok(())
// ============================================================
#[derive(clap::Args)]
struct CustomOpts {
/// Keep running
#[arg(short, long)]
keep_running: bool,
/// Do not spawn a node, reuse expected node on port 9944
no_spawn: bool,
/// For compliance with Jetbrains IDE which pushes extra args.
/// https://youtrack.jetbrains.com/issue/CPP-33071/cargo-test-adds-extra-options-which-conflict-with-Cucumber
#[arg(short, long)]
#[arg(short, long = "show-output")]
#[arg(short = 'Z', long)]
const DOCKER_FEATURES_PATH: &str = "/var/lib/duniter/cucumber-features";
const LOCAL_FEATURES_PATH: &str = "cucumber-features";
#[tokio::main(flavor = "current_thread")]
async fn main() {
//env_logger::init();
let features_path = if std::path::Path::new(DOCKER_FEATURES_PATH).exists() {
DOCKER_FEATURES_PATH
} else if std::path::Path::new(LOCAL_FEATURES_PATH).exists() {
LOCAL_FEATURES_PATH
} else {
panic!("cucumber-features folder not found");
};
let opts = cucumber::cli::Opts::<_, _, _, CustomOpts>::parsed();
let keep_running = opts.custom.keep_running;
let no_spawn = opts.custom.no_spawn;
// Handle crtl+C
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
ctrlc::set_handler(move || {
running_clone.store(false, Ordering::SeqCst);
})
.expect("Error setting Ctrl-C handler");
let summarize = DuniterWorld::cucumber()
.fail_on_skipped()
.before(move |feature, _rule, scenario, world| {
let mut genesis_conf_file_path = PathBuf::new();
genesis_conf_file_path.push("cucumber-genesis");
genesis_conf_file_path.push(&format!(
"{}.json",
genesis_conf_name(&feature.tags, &scenario.tags)
));
world.set_ignore_errors(ignore_errors(&scenario.tags));
Box::pin(world.init(Some(genesis_conf_file_path), no_spawn))
.after(move |_feature, _rule, _scenario, _ev, maybe_world| {
if keep_running {
while running.load(Ordering::SeqCst) {}
}
// Early kill (not waiting destructor) to save CPU/memory
if let Some(world) = maybe_world {
world.kill();
}
Box::pin(std::future::ready(()))
})
.with_cli(opts)
.run(features_path)
if summarize.failed_steps() > 0 {
panic!("Could not run tests correctly (failed steps)");
}
if summarize.hook_errors() > 0 {
panic!("Could not run tests correctly (hook errors)");
}
if summarize.parsing_errors() > 0 {
panic!("Could not run tests correctly (parsing errors)");
}
if summarize.execution_has_failed() {
panic!("Could not run tests correctly (execution has failed)");
}
fn genesis_conf_name(feature_tags: &[String], scenario_tags: &[String]) -> String {
for tag in scenario_tags {
if let Some(("genesis", conf_name)) = tag.split_once('.') {
return conf_name.to_owned();
}
}
for tag in feature_tags {
if let Some(("genesis", conf_name)) = tag.split_once('.') {
return conf_name.to_owned();
}
}
"default".to_owned()
}