//! `kv::` SDK bridge integration tests — runs a real Rhai engine //! against an in-memory `KvService` impl. Mirrors how //! `orchestrator-core::LocalExecutorClient` invokes the engine: under //! `tokio::task::spawn_blocking` so the bridge's `block_on` has a //! reachable runtime. use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use async_trait::async_trait; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_shared::{ AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services, }; use serde_json::{json, Value}; use tokio::sync::Mutex; #[derive(Default)] struct InMemoryKv { data: Mutex>, } #[async_trait] impl KvService for InMemoryKv { async fn get( &self, cx: &SdkCallCx, collection: &str, key: &str, ) -> Result, KvError> { Ok(self .data .lock() .await .get(&(cx.app_id, collection.to_string(), key.to_string())) .cloned()) } async fn set( &self, cx: &SdkCallCx, collection: &str, key: &str, value: Value, ) -> Result<(), KvError> { self.data .lock() .await .insert((cx.app_id, collection.to_string(), key.to_string()), value); Ok(()) } async fn set_if( &self, cx: &SdkCallCx, collection: &str, key: &str, expected: Option, new: Value, ) -> Result { let mut data = self.data.lock().await; let k = (cx.app_id, collection.to_string(), key.to_string()); let matches = match (&expected, data.get(&k)) { (None, None) => true, (Some(exp), Some(cur)) => exp == cur, _ => false, }; if matches { data.insert(k, new); } Ok(matches) } async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result { Ok(self .data .lock() .await .remove(&(cx.app_id, collection.to_string(), key.to_string())) .is_some()) } async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result { Ok(self.data.lock().await.contains_key(&( cx.app_id, collection.to_string(), key.to_string(), ))) } async fn list( &self, cx: &SdkCallCx, collection: &str, cursor: Option<&str>, limit: u32, ) -> Result { let data = self.data.lock().await; let mut keys: Vec = data .iter() .filter(|((a, c, _), _)| *a == cx.app_id && c == collection) .map(|((_, _, k), _)| k.clone()) .filter(|k| cursor.is_none_or(|c| k.as_str() > c)) .collect(); keys.sort(); let take = if limit == 0 { usize::MAX } else { limit as usize }; let next_cursor = if keys.len() > take { keys.truncate(take); keys.last().cloned() } else { None }; Ok(KvListPage { keys, next_cursor }) } } fn make_engine() -> Arc { let services = Services::new( Arc::new(InMemoryKv::default()), Arc::new(NoopDocsService), Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), Arc::new(NoopModuleSource), Arc::new(NoopHttpService), Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), Arc::new(picloud_shared::NoopSecretsService), Arc::new(picloud_shared::NoopEmailService), Arc::new(picloud_shared::NoopUsersService), Arc::new(picloud_shared::NoopQueueService), Arc::new(picloud_shared::NoopInvokeService), Arc::new(picloud_shared::NoopVarsService), ); Arc::new(Engine::new(Limits::default(), services)) } fn baseline_request(app_id: AppId) -> ExecRequest { let execution_id = ExecutionId::new(); ExecRequest { execution_id, request_id: RequestId::new(), script_id: ScriptId::new(), script_name: "kv-test".into(), invocation_type: InvocationType::Http, path: "/kv-test".into(), method: String::new(), headers: BTreeMap::new(), body: Value::Null, params: BTreeMap::new(), query: BTreeMap::new(), rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, script_owner: None, principal: None, trigger_depth: 0, root_execution_id: execution_id, is_dead_letter_handler: false, event: None, } } async fn run_script(engine: Arc, src: &str, req: ExecRequest) -> Value { let src = src.to_string(); tokio::task::spawn_blocking(move || engine.execute(&src, req)) .await .expect("spawn_blocking should not panic") .expect("script execution should succeed") .body } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn kv_set_then_get_round_trip() { let engine = make_engine(); let app = AppId::new(); let src = r#" let widgets = kv::collection("widgets"); widgets.set("k1", #{ n: 1 }); widgets.get("k1") "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!({ "n": 1 })); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn kv_set_if_compare_and_swap() { let engine = make_engine(); let app = AppId::new(); // `set_if(key, (), new)` inserts only when absent; `set_if(key, expected, // new)` swaps only on match. Returns a bool each time. let src = r#" let c = kv::collection("counters"); let first = c.set_if("n", (), 1); // absent -> inserts, true let dup = c.set_if("n", (), 2); // present -> false let bad = c.set_if("n", 99, 3); // mismatch -> false let good = c.set_if("n", 1, 3); // match -> swaps, true #{ first: first, dup: dup, bad: bad, good: good, final: c.get("n") } "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!( body, json!({ "first": true, "dup": false, "bad": false, "good": true, "final": 3 }) ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn kv_get_missing_returns_unit() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = kv::collection("widgets"); let v = c.get("nope"); v == () "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!(true)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn kv_has_returns_bool() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = kv::collection("widgets"); let before = c.has("k"); c.set("k", "v"); let after = c.has("k"); #{ before: before, after: after } "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!({ "before": false, "after": true })); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn kv_delete_returns_was_present() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = kv::collection("widgets"); let nope = c.delete("missing"); c.set("k", 1); let yep = c.delete("k"); #{ nope: nope, yep: yep } "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!({ "nope": false, "yep": true })); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn kv_empty_collection_name_throws() { let engine = make_engine(); let app = AppId::new(); let src = r#"kv::collection("")"#; let req = baseline_request(app); let err = tokio::task::spawn_blocking(move || engine.execute(src, req)) .await .unwrap() .expect_err("empty collection should throw"); assert!(format!("{err:?}").contains("kv::collection")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn kv_list_pages_via_cursor() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = kv::collection("widgets"); for i in 0..5 { c.set(`k${i}`, i); } let p1 = c.list("", 2); let p2 = c.list(p1.next_cursor, 2); #{ p1_keys: p1.keys, p1_cursor: p1.next_cursor, p2_keys: p2.keys, } "#; let body = run_script(engine, src, baseline_request(app)).await; let obj = body.as_object().unwrap(); let p1_keys = obj["p1_keys"].as_array().unwrap(); let p2_keys = obj["p2_keys"].as_array().unwrap(); assert_eq!(p1_keys.len(), 2); assert_eq!(p2_keys.len(), 2); assert!(obj["p1_cursor"].is_string()); } /// Cross-app isolation via `cx.app_id` — script with `app_id = A` /// cannot see entries from `app_id = B`. The kv:: bridge never /// surfaces `app_id` to the script, so this is enforced purely by the /// service deriving it from the captured `Arc`. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn kv_bridge_preserves_cross_app_isolation() { let engine = make_engine(); let app_a = AppId::new(); let app_b = AppId::new(); let writer = r#" let c = kv::collection("shared"); c.set("k", "from-a"); "ok" "#; let _ = run_script(engine.clone(), writer, baseline_request(app_a)).await; // App B sees nothing under the same collection/key. let reader = r#" let c = kv::collection("shared"); c.get("k") "#; let body = run_script(engine.clone(), reader, baseline_request(app_b)).await; assert_eq!(body, Value::Null, "app B must not see app A's value"); // Positive control — WITHOUT it this test has no teeth. `baseline_request` // mints a fresh execution_id AND script_id each call, so this second run under // app_a is a DIFFERENT execution/script than the writer. A bridge that keyed // storage by execution_id or script_id (instead of app_id) would pass the // negative above AND every single-execution round-trip, while app-scoped // persistence was completely gone. This catches that: app A must still read // back its own value across executions. let body = run_script(engine, reader, baseline_request(app_a)).await; assert_eq!( body, Value::from("from-a"), "app A must read back its own value in a later execution — storage is keyed \ by app_id, not execution_id/script_id" ); }