//! Golden SDK contract tests. //! //! **Editing this file is an SDK version bump event.** Every test here //! pins one documented user-visible behavior of the Rhai SDK exposed //! through `ctx`, `log::*`, and the response convention. Adding a //! function or field means adding a test here in the same change. //! Changing the meaning of an existing function means bumping //! `SDK_VERSION`'s major. //! //! Per `docs/versioning.md`, the rules are: //! //! * **Minor bump** (`1.0 → 1.1`) — additions only. Every test here //! for the previous minor must still pass. //! * **Major bump** (`1 → 2`) — removals, renames, retypes, //! restrictions. Tests in this file get updated; the SDK breaks. //! //! These tests live in `executor-core` because that crate owns the //! Rhai engine. They're separate from `engine.rs` because the audience //! differs: `engine.rs` tests internal Engine behaviors, this file //! tests the public contract scripts can rely on. Some overlap is //! intentional — the contract should be readable in one place. use std::collections::BTreeMap; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits, LogLevel}; use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox}; use serde_json::{json, Value}; // ---------------------------------------------------------------------------- // Test harness // ---------------------------------------------------------------------------- fn engine() -> Engine { Engine::new(Limits::default()) } fn baseline_request() -> ExecRequest { ExecRequest { execution_id: ExecutionId::new(), request_id: RequestId::new(), script_id: ScriptId::new(), script_name: "contract".into(), invocation_type: InvocationType::Http, path: "/contract-test".into(), headers: BTreeMap::new(), body: Value::Null, params: BTreeMap::new(), query: BTreeMap::new(), rest: String::new(), sandbox_overrides: ScriptSandbox::default(), } } fn run(source: &str) -> Value { engine() .execute(source, baseline_request()) .expect("contract test should execute cleanly") .body } fn run_with(source: &str, mutate: F) -> Value { let mut req = baseline_request(); mutate(&mut req); engine() .execute(source, req) .expect("contract test should execute cleanly") .body } // ============================================================================ // SDK 1.0 — execution context // ============================================================================ #[test] fn ctx_sdk_version_is_major_minor() { // The value MUST parse as integer.integer so scripts can do // `if ctx.sdk_version >= "1.2"` for feature detection. let body = run("ctx.sdk_version"); let v = body.as_str().expect("ctx.sdk_version is a string"); let parts: Vec<&str> = v.split('.').collect(); assert_eq!(parts.len(), 2, "expected MAJOR.MINOR, got {v:?}"); assert!(parts[0].parse::().is_ok()); assert!(parts[1].parse::().is_ok()); } #[test] fn ctx_execution_id_is_uuid_string() { let body = run("ctx.execution_id"); let s = body.as_str().expect("execution_id is a string"); assert_eq!(s.len(), 36, "expected UUID-shaped string, got {s:?}"); assert_eq!(s.matches('-').count(), 4); } #[test] fn ctx_request_id_is_uuid_string() { let body = run("ctx.request_id"); let s = body.as_str().expect("request_id is a string"); assert_eq!(s.len(), 36); assert_eq!(s.matches('-').count(), 4); } #[test] fn ctx_script_id_is_uuid_string() { let body = run("ctx.script_id"); let s = body.as_str().expect("script_id is a string"); assert_eq!(s.len(), 36); } #[test] fn ctx_script_name_round_trips() { let body = run_with("ctx.script_name", |r| r.script_name = "payments-v2".into()); assert_eq!(body, json!("payments-v2")); } #[test] fn ctx_invocation_type_http() { let body = run("ctx.invocation_type"); assert_eq!(body, json!("http")); } #[test] fn ctx_invocation_type_function() { let body = run_with("ctx.invocation_type", |r| { r.invocation_type = InvocationType::Function; }); assert_eq!(body, json!("function")); } #[test] fn ctx_invocation_type_scheduled() { let body = run_with("ctx.invocation_type", |r| { r.invocation_type = InvocationType::Scheduled; }); assert_eq!(body, json!("scheduled")); } // ============================================================================ // SDK 1.0 — request data // ============================================================================ #[test] fn ctx_request_path() { let body = run_with("ctx.request.path", |r| r.path = "/payments/webhook".into()); assert_eq!(body, json!("/payments/webhook")); } #[test] fn ctx_request_headers_map() { let body = run_with("ctx.request.headers[\"x-api-key\"]", |r| { r.headers.insert("x-api-key".into(), "secret".into()); }); assert_eq!(body, json!("secret")); } #[test] fn ctx_request_body_round_trip() { let body = run_with("ctx.request.body", |r| { r.body = json!({ "amount": 1234, "items": [1, 2, 3] }); }); assert_eq!(body, json!({ "amount": 1234, "items": [1, 2, 3] })); } #[test] fn ctx_request_body_null() { let body = run("ctx.request.body"); assert!(body.is_null()); } // ============================================================================ // SDK 1.0 — log::* functions // ============================================================================ #[test] fn log_info_single_arg() { let resp = engine() .execute(r#"log::info("hello"); 1"#, baseline_request()) .unwrap(); assert_eq!(resp.logs.len(), 1); assert_eq!(resp.logs[0].level, LogLevel::Info); assert_eq!(resp.logs[0].message, "hello"); assert!(resp.logs[0].data.is_none()); } #[test] fn log_info_with_data() { let resp = engine() .execute( r#"log::info("paid", #{ amount: 100, user: "alice" }); 1"#, baseline_request(), ) .unwrap(); assert_eq!(resp.logs.len(), 1); assert_eq!( resp.logs[0].data, Some(json!({ "amount": 100, "user": "alice" })) ); } #[test] fn log_all_four_levels() { let src = r#" log::trace("t"); log::info("i"); log::warn("w"); log::error("e"); 1 "#; let resp = engine().execute(src, baseline_request()).unwrap(); let levels: Vec<_> = resp.logs.iter().map(|l| l.level).collect(); assert_eq!( levels, vec![ LogLevel::Trace, LogLevel::Info, LogLevel::Warn, LogLevel::Error ] ); } #[test] fn log_debug_is_rejected_use_trace() { // `debug` is a Rhai reserved keyword — scripts MUST use `log::trace` // for sub-info diagnostics. This is documented in the SDK doc; the // assertion pins the rejection. let err = engine() .execute(r#"log::debug("nope"); 1"#, baseline_request()) .expect_err("log::debug must be rejected"); assert!(matches!(err, picloud_executor_core::ExecError::Parse(_))); } // ============================================================================ // SDK 1.0 — response convention // ============================================================================ #[test] fn bare_value_becomes_200_with_body() { let resp = engine().execute("42", baseline_request()).unwrap(); assert_eq!(resp.status_code, 200); assert_eq!(resp.body, json!(42)); assert!(resp.headers.is_empty()); } #[test] fn structured_response_passes_through() { let src = r#" #{ statusCode: 201, headers: #{ "x-tag": "on" }, body: #{ ok: true } } "#; let resp = engine().execute(src, baseline_request()).unwrap(); assert_eq!(resp.status_code, 201); assert_eq!(resp.headers.get("x-tag").map(String::as_str), Some("on")); assert_eq!(resp.body, json!({ "ok": true })); } #[test] fn missing_status_code_defaults_to_200() { // A Map without `statusCode` is just the body wrapped — falls back // to the bare-value rule, NOT to the structured shape. let resp = engine() .execute(r"#{ ok: true }", baseline_request()) .unwrap(); assert_eq!(resp.status_code, 200); assert_eq!(resp.body, json!({ "ok": true })); } #[test] fn structured_response_status_code_must_be_integer() { let err = engine() .execute( r#"#{ statusCode: "not-a-number", body: 1 }"#, baseline_request(), ) .expect_err("non-integer statusCode should fail"); assert!(matches!( err, picloud_executor_core::ExecError::InvalidResponse(_) )); } // ============================================================================ // SDK 1.0 — sandbox restrictions (what scripts CANNOT do) // ============================================================================ #[test] fn imports_are_blocked() { let err = engine() .execute(r#"import "external" as e; 1"#, baseline_request()) .expect_err("imports must be blocked"); assert!(matches!( err, picloud_executor_core::ExecError::Runtime(_) | picloud_executor_core::ExecError::Parse(_) )); } #[test] fn print_is_disabled() { // The `print` Rhai built-in is disabled; scripts route output // through `log::*` only. let err = engine() .execute(r#"print("nope"); 1"#, baseline_request()) .expect_err("print should be disabled"); assert!(matches!(err, picloud_executor_core::ExecError::Parse(_))); } // ============================================================================ // SDK 1.1 — route-extracted request data // ============================================================================ #[test] fn ctx_request_params_for_param_route() { let body = run_with("ctx.request.params", |r| { r.params.insert("name".into(), "alice".into()); r.params.insert("post".into(), "42".into()); }); assert_eq!(body, json!({ "name": "alice", "post": "42" })); } #[test] fn ctx_request_params_empty_when_no_route_captures() { let body = run("ctx.request.params"); assert_eq!(body, json!({})); } #[test] fn ctx_request_query_parses() { let body = run_with("ctx.request.query", |r| { r.query.insert("tab".into(), "details".into()); r.query.insert("page".into(), "2".into()); }); assert_eq!(body, json!({ "tab": "details", "page": "2" })); } #[test] fn ctx_request_query_empty_for_no_query_string() { let body = run("ctx.request.query"); assert_eq!(body, json!({})); } #[test] fn ctx_request_rest_for_prefix_route() { let body = run_with("ctx.request.rest", |r| r.rest = "alice/morning".into()); assert_eq!(body, json!("alice/morning")); } #[test] fn ctx_request_rest_empty_for_non_prefix_routes() { let body = run("ctx.request.rest"); assert_eq!(body, json!("")); } // ============================================================================ // SDK 1.0 — JSON type fidelity (what survives the bridge in both directions) // ============================================================================ #[test] fn json_round_trip_preserves_nested_shapes() { let body = run_with("ctx.request.body", |r| { r.body = json!({ "string": "value", "int": 42, "float": 2.5, "bool_t": true, "bool_f": false, "null": null, "list": [1, "two", 3.0, null, true], "nested": { "a": { "b": { "c": "deep" } } } }); }); assert_eq!(body["string"], json!("value")); assert_eq!(body["int"], json!(42)); assert_eq!(body["bool_t"], json!(true)); assert_eq!(body["bool_f"], json!(false)); assert!(body["null"].is_null()); assert_eq!(body["list"][1], json!("two")); assert_eq!(body["nested"]["a"]["b"]["c"], json!("deep")); } // ============================================================================ // Feature-detection scenarios (the reason ctx.sdk_version exists) // ============================================================================ #[test] fn scripts_can_feature_detect_minor_versions() { // A script written today for 1.1 should still parse and run on 1.x // for any x >= 1, with a fallback for older minors. This pattern is // the entire point of exposing ctx.sdk_version as major.minor. let src = r#" let v = ctx.sdk_version; if v >= "1.1" { #{ statusCode: 200, body: "modern" } } else { #{ statusCode: 200, body: "legacy" } } "#; let resp = engine().execute(src, baseline_request()).unwrap(); assert_eq!(resp.status_code, 200); assert_eq!(resp.body, json!("modern")); // we're at 1.1+ }