//! Integration tests for the v1.1.0 stdlib utility modules. //! //! These exist alongside `sdk_contract.rs` rather than inside it //! because the stateless utilities aren't part of the same versioned //! SDK contract surface — `sdk_contract.rs` covers things that bump //! `SDK_VERSION` when they change; stdlib additions don't. use std::collections::BTreeMap; use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits}; use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services}; use serde_json::{json, Value}; // ---------------------------------------------------------------------------- // Test harness — duplicated from sdk_contract.rs (each integration test // crate has its own; there is no tests/common/). // ---------------------------------------------------------------------------- fn engine() -> Engine { Engine::new(Limits::default(), Services::default()) } fn baseline_request() -> ExecRequest { let execution_id = ExecutionId::new(); ExecRequest { execution_id, request_id: RequestId::new(), script_id: ScriptId::new(), script_name: "stdlib".into(), invocation_type: InvocationType::Http, path: "/stdlib-test".into(), headers: BTreeMap::new(), body: Value::Null, params: BTreeMap::new(), query: BTreeMap::new(), rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id: AppId::new(), principal: None, trigger_depth: 0, root_execution_id: execution_id, is_dead_letter_handler: false, event: None, } } fn run(source: &str) -> Value { engine() .execute(source, baseline_request()) .expect("stdlib test should execute cleanly") .body } fn run_err(source: &str) -> ExecError { engine() .execute(source, baseline_request()) .expect_err("stdlib test expected to throw") } fn assert_runtime_err(err: ExecError, needle: &str) { match err { ExecError::Runtime(msg) => assert!( msg.contains(needle), "runtime error did not contain `{needle}`: {msg}" ), other => panic!("expected Runtime error containing `{needle}`, got {other:?}"), } } // ============================================================================ // regex // ============================================================================ #[test] fn regex_is_match_true_and_false() { assert_eq!(run(r#"regex::is_match("^h", "hello")"#), json!(true)); assert_eq!(run(r#"regex::is_match("^x", "hello")"#), json!(false)); } #[test] fn regex_find_returns_first_match() { assert_eq!(run(r#"regex::find("\\d+", "abc 42 def 99")"#), json!("42")); } #[test] fn regex_find_returns_unit_when_no_match() { // () serializes to JSON null via dynamic_to_json. assert_eq!(run(r#"regex::find("\\d+", "abc")"#), Value::Null); } #[test] fn regex_find_all_returns_array() { assert_eq!( run(r#"regex::find_all("\\d+", "a1 b22 c333")"#), json!(["1", "22", "333"]) ); } #[test] fn regex_replace_first_only() { assert_eq!( run(r#"regex::replace("a", "banana", "X")"#), json!("bXnana") ); } #[test] fn regex_replace_all() { assert_eq!( run(r#"regex::replace_all("a", "banana", "X")"#), json!("bXnXnX") ); } #[test] fn regex_split() { assert_eq!( run(r#"regex::split(",\\s*", "a, b,c, d")"#), json!(["a", "b", "c", "d"]) ); } #[test] fn regex_captures_extracts_groups() { assert_eq!( run(r#"regex::captures("(\\d+)-(\\w+)", "42-abc")"#), json!(["42-abc", "42", "abc"]) ); } #[test] fn regex_captures_returns_unit_when_no_match() { assert_eq!(run(r#"regex::captures("(\\d+)", "abc")"#), Value::Null); } #[test] fn regex_invalid_pattern_throws() { assert_runtime_err(run_err(r#"regex::is_match("(", "x")"#), "invalid regex"); } // ============================================================================ // random // ============================================================================ #[test] fn random_int_within_range() { // Run a few times to exercise the bounds — each call is independent. let body = run(r" let n = random::int(10, 20); n >= 10 && n <= 20 "); assert_eq!(body, json!(true)); } #[test] fn random_int_throws_when_min_greater_than_max() { assert_runtime_err(run_err("random::int(20, 10)"), "min"); } #[test] fn random_float_in_unit_interval() { let body = run(r" let f = random::float(); f >= 0.0 && f < 1.0 "); assert_eq!(body, json!(true)); } #[test] fn random_bytes_returns_blob_of_correct_length() { assert_eq!(run("random::bytes(16).len()"), json!(16)); } #[test] fn random_bytes_rejects_negative() { assert_runtime_err(run_err("random::bytes(-1)"), "random::bytes"); } #[test] fn random_bytes_rejects_oversize() { assert_runtime_err(run_err("random::bytes(70000)"), "random::bytes"); } #[test] fn random_string_produces_alphanumeric_of_correct_length() { let body = run(r#" let s = random::string(32); s.len == 32 && regex::is_match("^[A-Za-z0-9]+$", s) "#); assert_eq!(body, json!(true)); } #[test] fn random_uuid_has_canonical_format() { let body = run( r#"regex::is_match("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", random::uuid())"#, ); assert_eq!(body, json!(true)); } // ============================================================================ // time // ============================================================================ #[test] fn time_now_ms_is_positive() { let body = run("time::now_ms() > 0"); assert_eq!(body, json!(true)); } #[test] fn time_now_string_looks_like_iso() { let body = run(r#"regex::is_match("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}", time::now())"#); assert_eq!(body, json!(true)); } #[test] fn time_parse_format_round_trip() { let body = run(r" let ms = 1700000000000; time::parse(time::format(ms)) == ms "); assert_eq!(body, json!(true)); } #[test] fn time_add_seconds() { assert_eq!(run("time::add_seconds(0, 60)"), json!(60_000)); assert_eq!(run("time::add_seconds(1000, -1)"), json!(0)); } #[test] fn time_diff_seconds_truncates() { assert_eq!(run("time::diff_seconds(0, 65_500)"), json!(65)); } #[test] fn time_parse_rejects_garbage() { assert_runtime_err(run_err(r#"time::parse("nonsense")"#), "time::parse"); } // ============================================================================ // json // ============================================================================ #[test] fn json_parse_then_stringify_round_trip() { let body = run(r#" let src = `{"a":1,"b":"x"}`; json::stringify(json::parse(src)) == src "#); assert_eq!(body, json!(true)); } #[test] fn json_stringify_compact() { assert_eq!(run(r"json::stringify(#{ a: 1 })"), json!(r#"{"a":1}"#)); } #[test] fn json_stringify_pretty_has_newlines() { let body = run(r#"json::stringify_pretty(#{ a: 1 }).contains("\n")"#); assert_eq!(body, json!(true)); } #[test] fn json_parse_invalid_throws() { assert_runtime_err(run_err(r#"json::parse("not json")"#), "json::parse"); } // ============================================================================ // base64 // ============================================================================ #[test] fn base64_encode_string() { assert_eq!(run(r#"base64::encode("hi")"#), json!("aGk=")); } #[test] fn base64_decode_then_re_encode_round_trip() { assert_eq!( run(r#"base64::encode(base64::decode("aGVsbG8="))"#), json!("aGVsbG8=") ); } #[test] fn base64_encode_url_has_no_padding() { let body = run(r#" let s = base64::encode_url("hello world!?"); !s.contains("=") && !s.contains("+") && !s.contains("/") "#); assert_eq!(body, json!(true)); } #[test] fn base64_decode_url_round_trip() { assert_eq!( run(r#"base64::encode_url(base64::decode_url("aGVsbG8"))"#), json!("aGVsbG8") ); } #[test] fn base64_decode_invalid_throws() { assert_runtime_err(run_err(r#"base64::decode("!!!")"#), "base64::decode"); } // ============================================================================ // hex // ============================================================================ #[test] fn hex_encode_produces_lowercase() { assert_eq!(run(r#"hex::encode("Z")"#), json!("5a")); } #[test] fn hex_decode_then_re_encode_round_trip() { // mixed-case input → lowercase output proves both case-insensitive // decode and lowercase encode. assert_eq!( run(r#"hex::encode(hex::decode("DeAdBeEf"))"#), json!("deadbeef") ); } #[test] fn hex_decode_returns_correct_length() { assert_eq!(run(r#"hex::decode("deadbeef").len()"#), json!(4)); } #[test] fn hex_decode_invalid_throws() { assert_runtime_err(run_err(r#"hex::decode("xyz")"#), "hex::decode"); } // ============================================================================ // url // ============================================================================ #[test] fn url_encode_basic() { assert_eq!(run(r#"url::encode("hello world")"#), json!("hello%20world")); } #[test] fn url_encode_preserves_unreserved() { assert_eq!( run(r#"url::encode("abcXYZ123-_.~")"#), json!("abcXYZ123-_.~") ); } #[test] fn url_decode_round_trip() { assert_eq!( run(r#"url::decode(url::encode("hello world!?"))"#), json!("hello world!?") ); } #[test] fn url_encode_query_basic() { // Map keys come out alphabetically (Rhai's Map is a BTreeMap). assert_eq!( run(r#"url::encode_query(#{ a: "1", b: "x y" })"#), json!("a=1&b=x%20y") ); } #[test] fn url_encode_query_coerces_non_strings() { // Numbers and bools shouldn't throw; they coerce via to_string(). let body = run(r"url::encode_query(#{ n: 42, b: true })"); // Order is alphabetical: b before n. assert_eq!(body, json!("b=true&n=42")); } #[test] fn url_decode_rejects_invalid_utf8() { assert_runtime_err(run_err(r#"url::decode("%FF%FE%80")"#), "url::decode"); }