diff --git a/crates/cli/src/cli/build.rs b/crates/cli/src/cli/build.rs index cbf5044d..7e579cb7 100644 --- a/crates/cli/src/cli/build.rs +++ b/crates/cli/src/cli/build.rs @@ -312,4 +312,178 @@ mod tests { Ok(()) } + + use clap::Parser; + use std::io::Write; + use super::{Build, build}; + + /// Conversion normalizes the payload for the item's magic and then + /// applies the content encoding. + #[test] + fn test_item_normalize_then_encode() -> anyhow::Result<()> { + // "[ ]" normalizes to "[]" for a solidity ABI, then deflates. + let build_item = BuildItem { + data: "[ ]".as_bytes().to_vec(), + magic: KnownMagic::SolidityAbiV2, + content_type: ContentType::Json, + content_encoding: ContentEncoding::Deflate, + content_language: ContentLanguage::En, + }; + let meta_document = RainMetaDocumentV1Item::try_from(&build_item)?; + assert_eq!( + meta_document.payload.as_ref(), + ContentEncoding::Deflate.encode("[]".as_bytes()) + ); + + // Un-normalizable data is rejected. + let invalid_item = BuildItem { + data: "not json".as_bytes().to_vec(), + ..build_item + }; + assert!(RainMetaDocumentV1Item::try_from(&invalid_item).is_err()); + Ok(()) + } + + fn parse_build(args: &[&str]) -> Build { + Build::try_parse_from(args).unwrap() + } + + /// Each arity guard fires with its own message, before any file IO: + /// the input path never exists and yet the mismatch is what errors. + #[test] + fn test_build_arity_guards() { + let b = parse_build(&[ + "build", + "-i", + "does-not-exist.json", + "-m", + "solidity-abi-v2", + "-m", + "solidity-abi-v2", + ]); + assert_eq!( + build(b).unwrap_err().to_string(), + "1 inputs does not match 2 magic numbers." + ); + + let b = parse_build(&[ + "build", + "-i", + "does-not-exist.json", + "-m", + "solidity-abi-v2", + "-t", + "json", + "-t", + "json", + ]); + assert_eq!( + build(b).unwrap_err().to_string(), + "1 inputs does not match 2 content types." + ); + + let b = parse_build(&[ + "build", + "-i", + "does-not-exist.json", + "-m", + "solidity-abi-v2", + "-t", + "json", + "-e", + "identity", + "-e", + "identity", + ]); + assert_eq!( + build(b).unwrap_err().to_string(), + "1 inputs does not match 2 content encodings." + ); + + let b = parse_build(&[ + "build", + "-i", + "does-not-exist.json", + "-m", + "solidity-abi-v2", + "-t", + "json", + "-e", + "identity", + "-l", + "en", + "-l", + "en", + ]); + assert_eq!( + build(b).unwrap_err().to_string(), + "1 inputs does not match 2 content languages." + ); + } + + /// build() reads each input file, builds the document under the + /// global magic and writes it to the output path; the hex output + /// encoding is honored. + #[test] + fn test_build_reads_files_and_encodes_output() -> anyhow::Result<()> { + let mut input = tempfile::NamedTempFile::new()?; + input.write_all("[ ]".as_bytes())?; + let output = tempfile::NamedTempFile::new()?; + + let expected = build_bytes( + KnownMagic::RainMetaDocumentV1, + vec![BuildItem { + data: "[ ]".as_bytes().to_vec(), + magic: KnownMagic::SolidityAbiV2, + content_type: ContentType::Json, + content_encoding: ContentEncoding::Identity, + content_language: ContentLanguage::En, + }], + )?; + + let input_path = input.path().to_str().unwrap().to_string(); + let output_path = output.path().to_str().unwrap().to_string(); + + let b = parse_build(&[ + "build", + "-i", + &input_path, + "-m", + "solidity-abi-v2", + "-t", + "json", + "-e", + "identity", + "-l", + "en", + "-o", + &output_path, + ]); + build(b)?; + assert_eq!(std::fs::read(output.path())?, expected); + + let b = parse_build(&[ + "build", + "-i", + &input_path, + "-m", + "solidity-abi-v2", + "-t", + "json", + "-e", + "identity", + "-l", + "en", + "-o", + &output_path, + "-E", + "hex", + ]); + build(b)?; + assert_eq!( + std::fs::read_to_string(output.path())?, + alloy::primitives::hex::encode_prefixed(&expected) + ); + Ok(()) + } } diff --git a/crates/cli/src/cli/generate.rs b/crates/cli/src/cli/generate.rs index 090a5731..f5f3801b 100644 --- a/crates/cli/src/cli/generate.rs +++ b/crates/cli/src/cli/generate.rs @@ -124,7 +124,12 @@ mod tests { #[test] fn test_read_input_content_nonexistent_file() { let result = read_input_content(Some(PathBuf::from("/nonexistent/file.rain"))); - assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.to_string() + .contains("Failed to read file '/nonexistent/file.rain'"), + "unexpected error: {err}" + ); } #[test] @@ -144,6 +149,31 @@ mod tests { assert!(written_content.contains("0x1234567890abcdef")); assert!(written_content.contains("0xdeadbeef")); assert!(written_content.contains("0xcafebabe")); + + // The output is pretty-printed with two-space indentation, in + // field declaration order. + let expected = "{\n \"subject\": \"0x1234567890abcdef\",\n \"meta_bytes\": \"0xdeadbeef\",\n \"calldata\": \"0xcafebabe\"\n}"; + assert_eq!(written_content, expected); + } + + /// write_output creates missing parent directories of the output + /// path before writing. + #[test] + fn test_write_output_creates_parent_dirs() { + let deployment_data = DotrainSourceEmitData { + subject: "0x01".to_string(), + meta_bytes: "0x02".to_string(), + calldata: "0x03".to_string(), + }; + + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("a").join("b").join("out.json"); + write_output(&deployment_data, Some(nested.clone())).unwrap(); + + let written = fs::read_to_string(&nested).unwrap(); + assert!(written.contains("0x01")); + assert!(written.contains("0x02")); + assert!(written.contains("0x03")); } #[test] diff --git a/crates/cli/src/cli/output.rs b/crates/cli/src/cli/output.rs index a1e25534..7c4d8103 100644 --- a/crates/cli/src/cli/output.rs +++ b/crates/cli/src/cli/output.rs @@ -32,3 +32,36 @@ pub fn output( } Ok(()) } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + + /// Binary encoding writes the bytes through unchanged. + #[test] + fn test_output_binary_writes_exact_bytes_to_file() { + let file = tempfile::NamedTempFile::new().unwrap(); + let path = file.path().to_path_buf(); + output( + &Some(path.clone()), + SupportedOutputEncoding::Binary, + &[0x00, 0x01, 0xff], + ) + .unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), vec![0x00, 0x01, 0xff]); + } + + /// Hex encoding writes 0x-prefixed lowercase hex. + #[test] + fn test_output_hex_writes_prefixed_hex_to_file() { + let file = tempfile::NamedTempFile::new().unwrap(); + let path = file.path().to_path_buf(); + output( + &Some(path.clone()), + SupportedOutputEncoding::Hex, + &[0x00, 0x01, 0xff], + ) + .unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "0x0001ff"); + } +} diff --git a/crates/cli/src/cli/schema/show.rs b/crates/cli/src/cli/schema/show.rs index a0226f04..7ef01360 100644 --- a/crates/cli/src/cli/schema/show.rs +++ b/crates/cli/src/cli/schema/show.rs @@ -45,3 +45,75 @@ pub fn show(s: Show) -> anyhow::Result<()> { schema_string.as_bytes(), ) } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + + fn show_to_string(schema: KnownMeta, pretty_print: bool) -> anyhow::Result { + let file = tempfile::NamedTempFile::new().unwrap(); + let path = file.path().to_path_buf(); + show(Show { + schema, + output_path: Some(path.clone()), + pretty_print, + })?; + Ok(std::fs::read_to_string(&path).unwrap()) + } + + /// Each supported meta produces its own schema; compact output by + /// default (no newlines). + #[test] + fn test_show_op_v1_schema_compact() { + let s = show_to_string(KnownMeta::OpV1, false).unwrap(); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["title"], "OpMeta."); + assert!(!s.contains('\n')); + } + + /// The pretty flag pretty-prints the same schema. + #[test] + fn test_show_pretty_print() { + let s = show_to_string(KnownMeta::OpV1, true).unwrap(); + assert!(s.starts_with("{\n")); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + assert_eq!(v["title"], "OpMeta."); + } + + /// All four supported arms return the schema of their own meta type. + #[test] + fn test_show_supported_schemas_are_distinct() { + let op = show_to_string(KnownMeta::OpV1, false).unwrap(); + assert!(op.contains("OpMeta")); + let authoring = show_to_string(KnownMeta::AuthoringMetaV1, false).unwrap(); + assert!(authoring.contains("AuthoringMeta")); + let solidity = show_to_string(KnownMeta::SolidityAbiV2, false).unwrap(); + assert!(solidity.contains("SolidityAbi")); + let caller = show_to_string(KnownMeta::InterpreterCallerMetaV1, false).unwrap(); + assert!(caller.contains("InterpreterCallerMeta")); + for pair in [ + (&op, &authoring), + (&op, &solidity), + (&op, &caller), + (&authoring, &solidity), + (&authoring, &caller), + (&solidity, &caller), + ] { + assert_ne!(pair.0, pair.1); + } + } + + /// Metas without a JSON schema error with the exact unsupported + /// message. + #[test] + fn test_show_unsupported_meta_error() { + let file = tempfile::NamedTempFile::new().unwrap(); + let err = show(Show { + schema: KnownMeta::DotrainV1, + output_path: Some(file.path().to_path_buf()), + pretty_print: false, + }) + .unwrap_err(); + assert_eq!(err.to_string(), "Unsupported for dotrain-v1 meta"); + } +} diff --git a/crates/cli/src/cli/validate.rs b/crates/cli/src/cli/validate.rs index eaf00915..7578d0db 100644 --- a/crates/cli/src/cli/validate.rs +++ b/crates/cli/src/cli/validate.rs @@ -20,3 +20,34 @@ pub fn validate(v: Validate) -> anyhow::Result<()> { let _normalized = v.meta.normalize(&data)?; Ok(()) } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use std::io::Write; + + /// A meta that normalizes is valid. + #[test] + fn test_validate_ok_for_valid_meta() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(b"[]").unwrap(); + let v = Validate { + meta: KnownMeta::SolidityAbiV2, + input_path: file.path().to_path_buf(), + }; + assert!(validate(v).is_ok()); + } + + /// A meta that does not normalize is invalid: validity IS + /// normalizability. + #[test] + fn test_validate_err_for_invalid_meta() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(b"{\"not\": \"an abi\"}").unwrap(); + let v = Validate { + meta: KnownMeta::SolidityAbiV2, + input_path: file.path().to_path_buf(), + }; + assert!(validate(v).is_err()); + } +} diff --git a/crates/cli/src/meta/types/interpreter_caller/v1.rs b/crates/cli/src/meta/types/interpreter_caller/v1.rs index aa228bb6..3ad685f1 100644 --- a/crates/cli/src/meta/types/interpreter_caller/v1.rs +++ b/crates/cli/src/meta/types/interpreter_caller/v1.rs @@ -159,3 +159,219 @@ pub struct ContextCell { #[validate] pub alias: Option, } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use crate::meta::{ContentEncoding, ContentLanguage, ContentType, KnownMagic}; + + /// A fully-populated valid InterpreterCallerMeta JSON document. + fn valid_json() -> serde_json::Value { + serde_json::json!({ + "name": "Test Caller", + "abiName": "TestCaller", + "desc": "A caller for tests.", + "source": "https://github.com/rainlanguage/rain.metadata", + "alias": "test-caller", + "methods": [{ + "name": "Add Order", + "abiName": "addOrder", + "desc": "Adds an order.", + "inputs": [{ + "name": "Order", + "abiName": "order", + "desc": "The order.", + "path": "[0]" + }], + "expressions": [{ + "name": "Calculate", + "abiName": "calculateOrder", + "desc": "Calculates.", + "path": "[0].evaluableConfig", + "signedContext": true, + "callerContext": true, + "contextColumns": [{ + "name": "Base", + "desc": "Base column.", + "alias": "base", + "cells": [{ + "name": "Sender", + "desc": "The sender.", + "alias": "sender" + }] + }] + }] + }] + }) + } + + fn parse(v: &serde_json::Value) -> Result { + InterpreterCallerMeta::try_from(serde_json::to_vec(v).unwrap()) + } + + /// Omitted optional fields parse and take their documented defaults: + /// empty desc/source, no alias, both context flags false, no context + /// columns, no cells. + #[test] + fn test_serde_defaults() { + let v = serde_json::json!({ + "name": "Test Caller", + "abiName": "TestCaller", + "methods": [{ + "name": "Add Order", + "abiName": "addOrder", + "inputs": [{ + "name": "Order", + "abiName": "order", + "path": "[0]" + }], + "expressions": [ + { + "name": "Calculate", + "abiName": "calculateOrder", + "path": "[0].evaluableConfig", + "contextColumns": [{ + "name": "Base" + }] + }, + { + "name": "Handle", + "abiName": "handleOrder", + "path": "[1].evaluableConfig" + } + ] + }] + }); + let parsed = parse(&v).unwrap(); + assert_eq!(parsed.desc.value, ""); + assert_eq!(parsed.source.value, ""); + assert!(parsed.alias.is_none()); + let method = &parsed.methods[0]; + assert_eq!(method.desc.value, ""); + assert_eq!(method.inputs[0].desc.value, ""); + let expression = &method.expressions[0]; + assert_eq!(expression.desc.value, ""); + assert!(!expression.signed_context); + assert!(!expression.caller_context); + let column = &expression.context_columns[0]; + assert_eq!(column.desc.value, ""); + assert!(column.alias.is_none()); + assert!(column.cells.is_empty()); + assert!(method.expressions[1].context_columns.is_empty()); + } + + /// Invalid nested values fail validation at every depth of the + /// #[validate] chain: methods -> inputs and + /// methods -> expressions -> context_columns -> cells. + #[test] + fn test_nested_validate_chain() { + // Baseline sanity: the valid document parses and validates. + assert!(parse(&valid_json()).is_ok()); + + // A RainTitle must not begin with a space: " x" is invalid at + // any depth. + for pointer in [ + "/methods/0/name", + "/methods/0/inputs/0/name", + "/methods/0/expressions/0/name", + "/methods/0/expressions/0/contextColumns/0/name", + "/methods/0/expressions/0/contextColumns/0/cells/0/name", + ] { + let mut v = valid_json(); + *v.pointer_mut(pointer).unwrap() = serde_json::Value::String(" x".to_string()); + let err = parse(&v).unwrap_err(); + assert!( + matches!(err, Error::ValidationErrors(_)), + "expected validation error for {pointer}, got {err:?}" + ); + } + } + + /// methods and inputs require at least one element; context_columns + /// allows at most u8::MAX (255) elements. + #[test] + fn test_length_constraints() { + let mut v = valid_json(); + *v.pointer_mut("/methods").unwrap() = serde_json::json!([]); + assert!(matches!(parse(&v).unwrap_err(), Error::ValidationErrors(_))); + + let mut v = valid_json(); + *v.pointer_mut("/methods/0/inputs").unwrap() = serde_json::json!([]); + assert!(matches!(parse(&v).unwrap_err(), Error::ValidationErrors(_))); + + let column = serde_json::json!({ "name": "Col" }); + let mut v = valid_json(); + *v.pointer_mut("/methods/0/expressions/0/contextColumns") + .unwrap() = serde_json::Value::Array(vec![column.clone(); 255]); + assert!(parse(&v).is_ok()); + + let mut v = valid_json(); + *v.pointer_mut("/methods/0/expressions/0/contextColumns") + .unwrap() = serde_json::Value::Array(vec![column; 256]); + assert!(matches!(parse(&v).unwrap_err(), Error::ValidationErrors(_))); + } + + /// TryFrom> and TryFrom<&[u8]> validate after parsing: + /// syntactically-valid JSON with semantically-invalid values errors, + /// and valid documents round-trip with their values intact. + #[test] + fn test_try_from_bytes_validates() { + let mut invalid = valid_json(); + *invalid.pointer_mut("/name").unwrap() = serde_json::Value::String(" x".to_string()); + let bytes = serde_json::to_vec(&invalid).unwrap(); + + let err = InterpreterCallerMeta::try_from(bytes.clone()).unwrap_err(); + assert!(matches!(err, Error::ValidationErrors(_))); + let err = InterpreterCallerMeta::try_from(bytes.as_slice()).unwrap_err(); + assert!(matches!(err, Error::ValidationErrors(_))); + + let valid_bytes = serde_json::to_vec(&valid_json()).unwrap(); + let ok = InterpreterCallerMeta::try_from(valid_bytes.clone()).unwrap(); + assert_eq!(ok.name.value, "Test Caller"); + assert_eq!(ok.abi_name.value, "TestCaller"); + let ok = InterpreterCallerMeta::try_from(valid_bytes.as_slice()).unwrap(); + assert_eq!(ok.methods[0].abi_name.value, "addOrder"); + } + + /// TryFrom unpacks the payload per the item's + /// content encoding before parsing. + #[test] + fn test_try_from_meta_item_unpacks_encoding() { + let json_bytes = serde_json::to_vec(&valid_json()).unwrap(); + let item = RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(ContentEncoding::Deflate.encode(&json_bytes)), + magic: KnownMagic::InterpreterCallerMetaV1, + content_type: ContentType::Json, + content_encoding: ContentEncoding::Deflate, + content_language: ContentLanguage::En, + schema: None, + }; + let parsed = InterpreterCallerMeta::try_from(item).unwrap(); + assert_eq!(parsed.name.value, "Test Caller"); + assert_eq!(parsed.methods.len(), 1); + } + + /// Unknown fields are rejected. + #[test] + fn test_deny_unknown_fields() { + let mut v = valid_json(); + v.as_object_mut() + .unwrap() + .insert("unknownField".to_string(), serde_json::json!(1)); + let err = parse(&v).unwrap_err(); + assert!(matches!(err, Error::SerdeJsonError(_))); + } + + /// Field names are camelCase on the wire; the snake_case spelling is + /// an unknown field. + #[test] + fn test_camel_case_wire_format() { + assert!(parse(&valid_json()).is_ok()); + + let mut v = valid_json(); + let obj = v.as_object_mut().unwrap(); + let abi = obj.remove("abiName").unwrap(); + obj.insert("abi_name".to_string(), abi); + assert!(parse(&v).is_err()); + } +} diff --git a/crates/cli/src/metaboard.rs b/crates/cli/src/metaboard.rs index 8469ad12..d2fbfa68 100644 --- a/crates/cli/src/metaboard.rs +++ b/crates/cli/src/metaboard.rs @@ -147,6 +147,24 @@ mod tests { let meta_bytes_from_field = alloy::hex::decode(deployment.meta_bytes.trim_start_matches("0x")).unwrap(); assert_eq!(decoded.meta.as_ref(), meta_bytes_from_field.as_slice()); + + // meta_bytes is a cbor-seq under the rain meta document magic + // prefix 0xff0a89c674ee7874. + assert!(deployment.meta_bytes.starts_with("0xff0a89c674ee7874")); + + // The subject is the keccak256 of the BARE cbor item map — no + // rain-meta-document magic prefix — pinned here against an + // independently constructed item. + let item = RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(content.as_bytes().to_vec()), + magic: KnownMagic::DotrainSourceV1, + content_type: ContentType::OctetStream, + content_encoding: ContentEncoding::None, + content_language: ContentLanguage::None, + schema: None, + }; + let expected_subject = alloy::primitives::keccak256(item.cbor_encode().unwrap()); + assert_eq!(deployment.subject, hex::encode_prefixed(expected_subject)); } #[test] diff --git a/crates/cli/src/solc/mod.rs b/crates/cli/src/solc/mod.rs index 1e6d10e0..abbe30cb 100644 --- a/crates/cli/src/solc/mod.rs +++ b/crates/cli/src/solc/mod.rs @@ -27,3 +27,53 @@ pub fn extract_artifact_component_json( ArtifactComponent::DeployedBytecode => Ok(json["deployedBytecode"].clone()), } } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + + fn artifact_json() -> Vec { + serde_json::json!({ + "abi": [{ "type": "function", "name": "foo" }], + "bytecode": { "object": "0x6001" }, + "deployedBytecode": { "object": "0x6002" } + }) + .to_string() + .into_bytes() + } + + /// Each component arm extracts exactly its own key. + #[test] + fn test_extract_each_component() { + let data = artifact_json(); + assert_eq!( + extract_artifact_component_json(ArtifactComponent::Abi, &data).unwrap(), + serde_json::json!([{ "type": "function", "name": "foo" }]) + ); + assert_eq!( + extract_artifact_component_json(ArtifactComponent::Bytecode, &data).unwrap(), + serde_json::json!({ "object": "0x6001" }) + ); + assert_eq!( + extract_artifact_component_json(ArtifactComponent::DeployedBytecode, &data).unwrap(), + serde_json::json!({ "object": "0x6002" }) + ); + } + + /// Documented: no null check is performed — a missing component is + /// returned as JSON null, not an error. + #[test] + fn test_missing_component_returns_null() { + assert_eq!( + extract_artifact_component_json(ArtifactComponent::Abi, b"{}").unwrap(), + serde_json::Value::Null + ); + } + + /// Non-utf8 and non-json inputs error. + #[test] + fn test_invalid_input_errors() { + assert!(extract_artifact_component_json(ArtifactComponent::Abi, &[0xff, 0xfe]).is_err()); + assert!(extract_artifact_component_json(ArtifactComponent::Abi, b"not json").is_err()); + } +} diff --git a/crates/cli/src/subgraph/mod.rs b/crates/cli/src/subgraph/mod.rs index 91cba73b..a86817dc 100644 --- a/crates/cli/src/subgraph/mod.rs +++ b/crates/cli/src/subgraph/mod.rs @@ -58,3 +58,93 @@ impl KnownSubgraphs { } } } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + + const ETH_LEGACY: &str = + "https://api.thegraph.com/subgraphs/name/rainlanguage/interpreter-registry-ethereum"; + const ETH_NP: &str = + "https://api.thegraph.com/subgraphs/name/rainlanguage/interpreter-registry-np-eth"; + const ETH_NPE2: &str = + "https://api.thegraph.com/subgraphs/name/rainlanguage/interpreter-registry-npe2-eth"; + const POLY_LEGACY: &str = + "https://api.thegraph.com/subgraphs/name/rainlanguage/interpreter-registry-polygon"; + const POLY_NP: &str = + "https://api.thegraph.com/subgraphs/name/rainlanguage/interpreter-registry-np-matic"; + const POLY_NPE2: &str = + "https://api.thegraph.com/subgraphs/name/rainlanguage/interpreter-registry-npe2-mati"; + const MUMBAI_LEGACY: &str = + "https://api.thegraph.com/subgraphs/name/rainlanguage/interpreter-registry"; + const MUMBAI_NP: &str = + "https://api.thegraph.com/subgraphs/name/rainlanguage/interpreter-registry-np"; + const MUMBAI_NPE2: &str = + "https://api.thegraph.com/subgraphs/name/rainlanguage/interpreter-registry-npe2"; + + /// The per-network triples are [legacy, np, npe2] with exactly these + /// URLs. + #[test] + fn test_network_triples_are_exact() { + assert_eq!(KnownSubgraphs::ETHEREUM, [ETH_LEGACY, ETH_NP, ETH_NPE2]); + assert_eq!(KnownSubgraphs::POLYGON, [POLY_LEGACY, POLY_NP, POLY_NPE2]); + assert_eq!( + KnownSubgraphs::MUMBAI, + [MUMBAI_LEGACY, MUMBAI_NP, MUMBAI_NPE2] + ); + } + + /// The flavor slices pick the same column from every network, and ALL + /// concatenates the three networks in order. + #[test] + fn test_flavor_slices_and_all() { + assert_eq!( + KnownSubgraphs::LEGACY, + [ETH_LEGACY, POLY_LEGACY, MUMBAI_LEGACY] + ); + assert_eq!(KnownSubgraphs::NP, [ETH_NP, POLY_NP, MUMBAI_NP]); + assert_eq!(KnownSubgraphs::NPE2, [ETH_NPE2, POLY_NPE2, MUMBAI_NPE2]); + assert_eq!( + KnownSubgraphs::ALL, + [ + ETH_LEGACY, + ETH_NP, + ETH_NPE2, + POLY_LEGACY, + POLY_NP, + POLY_NPE2, + MUMBAI_LEGACY, + MUMBAI_NP, + MUMBAI_NPE2, + ] + ); + } + + /// of_chain maps 1/137/80001 to their networks. + #[test] + fn test_of_chain_known_networks() { + assert_eq!( + KnownSubgraphs::of_chain(1).unwrap(), + KnownSubgraphs::ETHEREUM + ); + assert_eq!( + KnownSubgraphs::of_chain(137).unwrap(), + KnownSubgraphs::POLYGON + ); + assert_eq!( + KnownSubgraphs::of_chain(80001).unwrap(), + KnownSubgraphs::MUMBAI + ); + } + + /// Every other chain id is unsupported. + #[test] + fn test_of_chain_unknown_network_errors() { + for id in [0u64, 2, 100, 8453, u64::MAX] { + assert!(matches!( + KnownSubgraphs::of_chain(id), + Err(Error::UnsupportedNetwork) + )); + } + } +} diff --git a/crates/cli/tests/cli.rs b/crates/cli/tests/cli.rs new file mode 100644 index 00000000..a6306d12 --- /dev/null +++ b/crates/cli/tests/cli.rs @@ -0,0 +1,146 @@ +//! End-to-end tests of the rain-metadata binary's stdin/stdout behaviour, +//! which in-process unit tests cannot observe. +#![cfg(not(target_family = "wasm"))] + +use std::io::Write; +use std::process::{Command, Stdio}; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_rain-metadata")) +} + +/// `magic ls` prints every known magic number, in declaration order, as +/// `{:#x} {kebab-name}` lines. Expected list is derived from the magic +/// number table of the rain metadata-v1 spec: +/// https://github.com/rainprotocol/specs/blob/main/metadata-v1.md +#[test] +fn magic_ls_prints_all_known_magic_numbers() { + let out = bin().args(["magic", "ls"]).output().unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + let expected = "\ +0xff0a89c674ee7874 rain-meta-document-v1 +0xffe5282f43e495b4 op-meta-v1 +0xffdac2f2f37be894 dotrain-v1 +0xff1c198cec3b48a7 rainlang-v1 +0xffe5ffb4a3ff2cde solidity-abi-v2 +0xffe9e3a02ca8e235 authoring-meta-v1 +0xff52fe42f1a05093 authoring-meta-v2 +0xffc21bbf86cc199b interpreter-caller-meta-v1 +0xffdb988a8cd04d32 expression-deployer-v2-bytecode-v1 +0xff13109e41336ff2 rainlang-source-v1 +0xffb2637608c09e38 address-list +0xffa15ef0fc437099 dotrain-source-v1 +0xffda7b2fb167c286 order-builder-state-v1 +0xff7a1507ba4419ca raindex-signed-context-oracle-v1 +0xffa8e8a9b9cf4a31 oa-schema +0xff9fae3cc645f463 oa-hash-list +0xffc47a6299e8a911 oa-structure +0xff8cd2927c8c86cb oa-token-image +0xffbc38eb14ad2209 oa-token-credential-links +"; + assert_eq!(stdout, expected); +} + +/// `schema ls` prints every known meta identifier in declaration order. +#[test] +fn schema_ls_prints_all_known_metas() { + let out = bin().args(["schema", "ls"]).output().unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + let expected = "\ +op-v1 +dotrain-v1 +rainlang-v1 +solidity-abi-v2 +authoring-meta-v1 +authoring-meta-v2 +interpreter-caller-meta-v1 +expression-deployer-v2-bytecode-v1 +rainlang-source-v1 +address-list +dotrain-source-v1 +order-builder-state-v1 +raindex-signed-context-oracle-v1 +"; + assert_eq!(stdout, expected); +} + +/// `schema show` writes the schema to stdout when no output path is +/// given (the stdout branch of cli::output::output). +#[test] +fn schema_show_prints_op_meta_schema_to_stdout() { + let out = bin().args(["schema", "show", "op-v1"]).output().unwrap(); + assert!(out.status.success()); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["title"], "OpMeta."); +} + +/// `schema-check` reports the number of verified entities and the source +/// label on success. +#[test] +fn schema_check_prints_verified_entity_count() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("schema.graphql"); + std::fs::write( + &source, + "type MetaBoard @entity { id: Bytes! }\ntype MetaV1 @entity { id: ID! }\n", + ) + .unwrap(); + let consumer = dir.path().join("consumer.graphql"); + std::fs::write( + &consumer, + "type MetaBoard { id: Bytes! }\ntype MetaV1 { id: ID! }\n", + ) + .unwrap(); + + let out = bin() + .args([ + "schema-check", + "--source", + source.to_str().unwrap(), + "--consumer", + consumer.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert_eq!( + stdout, + "schema check ok: 2 entities verified against source\n" + ); +} + +/// `generate source` reads the dotrain content from stdin when no input +/// path is given, and writes the emit data JSON to stdout when no output +/// path is given. +#[test] +fn generate_source_reads_stdin_and_writes_stdout() { + let content = "#main _ _: int-add(1 2);"; + let mut child = bin() + .args(["generate", "source"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .as_mut() + .unwrap() + .write_all(content.as_bytes()) + .unwrap(); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let subject = v["subject"].as_str().unwrap(); + assert!(subject.starts_with("0x")); + assert_eq!(subject.len(), 66); + // The meta bytes carry the rain meta document magic prefix. + assert!(v["meta_bytes"] + .as_str() + .unwrap() + .starts_with("0xff0a89c674ee7874")); + assert!(v["calldata"].as_str().unwrap().starts_with("0x")); +}