//! `docs::` SDK bridge integration tests — runs a real Rhai engine //! against an in-memory `DocsService` impl. Mirrors `tests/sdk_kv.rs`: //! `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 chrono::Utc; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_shared::{ AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService, NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services, }; use serde_json::{json, Value}; use tokio::sync::Mutex; use uuid::Uuid; #[derive(Default)] struct InMemoryDocs { data: Mutex>, } #[async_trait] impl DocsService for InMemoryDocs { async fn create( &self, cx: &SdkCallCx, collection: &str, data: Value, ) -> Result { if !data.is_object() { return Err(DocsError::InvalidData); } let id = Uuid::new_v4(); let now = Utc::now(); let row = DocRow { id, data, created_at: now, updated_at: now, }; self.data .lock() .await .insert((cx.app_id, collection.to_string(), id), row); Ok(id) } async fn get( &self, cx: &SdkCallCx, collection: &str, id: DocId, ) -> Result, DocsError> { Ok(self .data .lock() .await .get(&(cx.app_id, collection.to_string(), id)) .cloned()) } async fn find( &self, cx: &SdkCallCx, collection: &str, filter: Value, ) -> Result, DocsError> { // Tiny eval: extract top-level equalities + $in arrays + $gt // (text lex) so the bridge tests can run end-to-end against a // fake. This fake mirrors the real service's reject-unsupported // contract so the v1.2-pointer-error test goes through the // bridge's error-propagation path. let map = self.data.lock().await; let obj = filter .as_object() .ok_or_else(|| DocsError::InvalidFilter("filter must be a map/object".into()))?; reject_unsupported_operators(obj)?; let mut out: Vec = map .iter() .filter(|((a, c, _), _)| *a == cx.app_id && c == collection) .map(|(_, v)| v.clone()) .filter(|row| matches_simple(&row.data, obj)) .collect(); if let Some(limit) = obj.get("$limit").and_then(Value::as_u64) { out.truncate(usize::try_from(limit).unwrap_or(usize::MAX)); } Ok(out) } async fn find_one( &self, cx: &SdkCallCx, collection: &str, filter: Value, ) -> Result, DocsError> { Ok(self.find(cx, collection, filter).await?.into_iter().next()) } async fn update( &self, cx: &SdkCallCx, collection: &str, id: DocId, data: Value, ) -> Result<(), DocsError> { if !data.is_object() { return Err(DocsError::InvalidData); } let mut map = self.data.lock().await; let key = (cx.app_id, collection.to_string(), id); let Some(row) = map.get_mut(&key) else { return Err(DocsError::NotFound); }; row.data = data; row.updated_at = Utc::now(); Ok(()) } async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result { Ok(self .data .lock() .await .remove(&(cx.app_id, collection.to_string(), id)) .is_some()) } async fn list( &self, cx: &SdkCallCx, collection: &str, _cursor: Option<&str>, _limit: u32, ) -> Result { let mut docs: Vec = self .data .lock() .await .iter() .filter(|((a, c, _), _)| *a == cx.app_id && c == collection) .map(|(_, v)| v.clone()) .collect(); docs.sort_by_key(|d| d.id); Ok(DocsListPage { docs, next_cursor: None, }) } } /// Scan an operator object for any `$xxx` key not in the v1.1.2 /// allowlist and return the same shape of error the real parser /// emits. Top-level `$limit` is the only allowed modifier the fake /// engages with; the unsupported test passes `$regex`. fn reject_unsupported_operators(obj: &serde_json::Map) -> Result<(), DocsError> { const SUPPORTED_TOP_LEVEL: &[&str] = &["$limit", "$sort"]; const SUPPORTED_NESTED: &[&str] = &["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"]; for (key, value) in obj { if let Some(stripped) = key.strip_prefix('$') { if !SUPPORTED_TOP_LEVEL.contains(&key.as_str()) { return Err(DocsError::UnsupportedOperator(format!( "docs::find: top-level modifier '${stripped}' is not supported in v1.1.2; planned for v1.2 advanced query" ))); } continue; } if let Some(inner) = value.as_object() { for op_key in inner.keys() { if op_key.starts_with('$') && !SUPPORTED_NESTED.contains(&op_key.as_str()) { return Err(DocsError::UnsupportedOperator(format!( "docs::find: operator '{op_key}' is not supported in v1.1.2; planned for v1.2 advanced query" ))); } } } } Ok(()) } fn matches_simple(data: &Value, filter: &serde_json::Map) -> bool { for (key, want) in filter { if key.starts_with('$') { // $limit handled in the find body. continue; } let actual = data.get(key); if let Some(obj) = want.as_object() { // operator object — handle $in and $gt only (enough for // the bridge tests to exercise the round-trip). if let Some(arr) = obj.get("$in").and_then(Value::as_array) { let Some(actual) = actual else { return false; }; if !arr.iter().any(|v| v == actual) { return false; } continue; } if let Some(gt) = obj.get("$gt") { let Some(actual) = actual else { return false; }; let a = actual.as_str().unwrap_or(""); let b = gt.as_str().unwrap_or(""); if a <= b { return false; } continue; } return false; } if Some(want) != actual { return false; } } true } fn make_engine() -> Arc { let services = Services::new( Arc::new(NoopKvService), Arc::new(InMemoryDocs::default()), Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), Arc::new(NoopModuleSource), Arc::new(NoopHttpService), Arc::new(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), ); 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: "docs-test".into(), invocation_type: InvocationType::Http, path: "/docs-test".into(), headers: BTreeMap::new(), body: Value::Null, params: BTreeMap::new(), query: BTreeMap::new(), rest: String::new(), sandbox_overrides: ScriptSandbox::default(), app_id, 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 docs_create_then_get_round_trip() { let engine = make_engine(); let app = AppId::new(); let src = r#" let users = docs::collection("users"); let id = users.create(#{ name: "Alice", tier: "gold" }); let doc = users.get(id); #{ id_matches: doc.id == id, data_name: doc.data.name } "#; let body = run_script(engine, src, baseline_request(app)).await; let obj = body.as_object().unwrap(); assert_eq!(obj["id_matches"], json!(true)); assert_eq!(obj["data_name"], json!("Alice")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_get_missing_returns_unit() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = docs::collection("users"); let v = c.get("00000000-0000-0000-0000-000000000000"); 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 docs_get_with_invalid_uuid_throws() { let engine = make_engine(); let app = AppId::new(); let src = r#"docs::collection("users").get("not-a-uuid")"#; let req = baseline_request(app); let err = tokio::task::spawn_blocking(move || engine.execute(src, req)) .await .unwrap() .expect_err("invalid uuid should throw"); assert!(format!("{err:?}").contains("invalid id")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_find_equality_returns_matches() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = docs::collection("users"); c.create(#{ tier: "gold" }); c.create(#{ tier: "silver" }); c.create(#{ tier: "gold" }); let golds = c.find(#{ tier: "gold" }); golds.len() "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!(2)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_find_with_in_operator() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = docs::collection("users"); c.create(#{ tier: "gold" }); c.create(#{ tier: "silver" }); c.create(#{ tier: "platinum" }); let hits = c.find(#{ tier: #{ "$in": ["gold", "platinum"] } }); hits.len() "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!(2)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_find_with_gt_comparison() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = docs::collection("events"); c.create(#{ when: "2026-01-15" }); c.create(#{ when: "2026-03-15" }); c.create(#{ when: "2026-05-15" }); let recent = c.find(#{ when: #{ "$gt": "2026-02-01" } }); recent.len() "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!(2)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_find_one_returns_envelope_or_unit() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = docs::collection("users"); c.create(#{ tier: "gold" }); let hit = c.find_one(#{ tier: "gold" }); let miss = c.find_one(#{ tier: "platinum" }); #{ hit_has_data: hit.data.tier == "gold", miss_is_unit: miss == () } "#; let body = run_script(engine, src, baseline_request(app)).await; let obj = body.as_object().unwrap(); assert_eq!(obj["hit_has_data"], json!(true)); assert_eq!(obj["miss_is_unit"], json!(true)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_update_then_get_reflects_change() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = docs::collection("users"); let id = c.create(#{ name: "Alice", tier: "gold" }); c.update(id, #{ name: "Alice", tier: "platinum" }); c.get(id).data.tier "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!("platinum")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_update_missing_throws() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = docs::collection("users"); c.update("00000000-0000-0000-0000-000000000000", #{ x: 1 }) "#; let req = baseline_request(app); let err = tokio::task::spawn_blocking(move || engine.execute(src, req)) .await .unwrap() .expect_err("update missing should throw"); assert!(format!("{err:?}").contains("not found")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_delete_returns_was_present() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = docs::collection("users"); let nope = c.delete("00000000-0000-0000-0000-000000000000"); let id = c.create(#{ x: 1 }); let yep = c.delete(id); #{ 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 docs_unsupported_operator_throws_with_v1_2_pointer() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = docs::collection("users"); c.find(#{ name: #{ "$regex": "^A" } }) "#; let req = baseline_request(app); let err = tokio::task::spawn_blocking(move || engine.execute(src, req)) .await .unwrap() .expect_err("unsupported operator should throw"); let msg = format!("{err:?}"); assert!(msg.contains("$regex"), "msg: {msg}"); assert!(msg.contains("v1.2"), "msg: {msg}"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_empty_collection_name_throws() { let engine = make_engine(); let app = AppId::new(); let src = r#"docs::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("docs::collection")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_list_returns_docs_array() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = docs::collection("users"); c.create(#{ a: 1 }); c.create(#{ a: 2 }); let page = c.list(); page.docs.len() "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!(2)); } /// Cross-app isolation through the bridge — script with `app_id = A` /// must NOT see documents written from `app_id = B` even when the /// (collection, id) tuple is shared. The bridge captures `cx.app_id` /// via `Arc` and the service derives storage `app_id` from /// it (never from a script arg). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_bridge_preserves_cross_app_isolation() { let engine = make_engine(); let app_a = AppId::new(); let app_b = AppId::new(); let writer = r#" let c = docs::collection("shared"); let id = c.create(#{ from: "a" }); id "#; let id_a = run_script(engine.clone(), writer, baseline_request(app_a)).await; let id_a_str = id_a.as_str().unwrap().to_string(); // App B looks up the same id under the same collection — should // see nothing because the service keyed it by app_id = A. let reader_src = format!( r#" let c = docs::collection("shared"); let v = c.get("{id_a_str}"); v == () "# ); let body = run_script(engine, &reader_src, baseline_request(app_b)).await; assert_eq!(body, json!(true)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn docs_envelope_has_id_data_created_at_updated_at() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = docs::collection("users"); let id = c.create(#{ name: "Alice" }); let doc = c.get(id); // Probe each envelope field is present + correctly typed. #{ has_id: type_of(doc.id) == "string", has_data: type_of(doc.data) == "map", has_created_at: type_of(doc.created_at) == "string", has_updated_at: type_of(doc.updated_at) == "string", user_field: doc.data.name } "#; let body = run_script(engine, src, baseline_request(app)).await; let obj = body.as_object().unwrap(); assert_eq!(obj["has_id"], json!(true)); assert_eq!(obj["has_data"], json!(true)); assert_eq!(obj["has_created_at"], json!(true)); assert_eq!(obj["has_updated_at"], json!(true)); assert_eq!(obj["user_field"], json!("Alice")); }