Wires the KV store into Rhai scripts via the handle pattern:
let widgets = kv::collection("widgets");
widgets.set("k", #{ n: 1 });
let v = widgets.get("k"); // value or () if absent
widgets.has("k") / widgets.delete("k")
let page = widgets.list(); // cursor-style pagination
`KvHandle` is a custom Rhai type holding `Arc<dyn KvService>` + the
per-call `Arc<SdkCallCx>`. Methods route async service calls through
`tokio::Handle::current().block_on(...)` — works because
`LocalExecutorClient` runs the script under `spawn_blocking` so a
runtime is reachable. The bridge surfaces `app_id` exclusively
through `cx.app_id`; no public-facing argument can spoof an app.
`TriggerEvent` lands in `picloud-shared` as the wire shape the
dispatcher will emit (KV + DeadLetter variants — KV exercised now,
DL hooks up with the dispatcher in commit 5/8). `SdkCallCx` and
`ExecRequest` grow `is_dead_letter_handler: bool` and
`event: Option<TriggerEvent>`. `engine.rs::build_ctx_map` flattens
the event into `ctx.event` for triggered handlers; direct ingress
leaves the key absent so scripts can `if "event" in ctx`.
Tests:
- 7 `sdk_kv.rs` integration tests covering the full Rhai surface
(round-trip, missing-key unit, has bool, delete was-present,
empty-collection rejection, cursor pagination, cross-app
isolation through the bridge).
- 3 new `engine.rs` tests pinning `ctx.event` shape per
design notes §4 (KV insert with value, delete with unit value,
direct invocations have no `event` key).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
385 lines
10 KiB
Rust
385 lines
10 KiB
Rust
//! 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");
|
|
}
|