Files
PiCloud/crates/executor-core/tests/sdk_docs.rs
MechaCat02 834c787ee1 feat(v1.1.5): pubsub::publish_durable SDK + pubsub:* triggers
Durable pub/sub through the universal outbox — the sixth trigger kind.

- `pubsub::publish_durable(topic, message)` Rhai SDK (no handle; topics
  ARE the grouping unit). Message JSON-encoded; Blobs base64 at any
  depth.
- `PubsubService` trait in picloud-shared with the topic matcher +
  validator (exact / `<prefix>.*` / `*`; mid-pattern wildcards
  rejected). `PostgresPubsubRepo` + `PubsubServiceImpl` in manager-core.
- Publish-time fan-out: one outbox row per matching enabled pubsub
  trigger, all in ONE transaction (no half-fan-out on crash). No
  matching trigger → publish succeeds silently, zero rows.
- `pubsub:*` trigger kind via Layout-E (0020: widen both CHECKs +
  pubsub_trigger_details + partial index), TriggerEvent::Pubsub +
  ctx.event.pubsub, dispatcher arm, admin endpoint POST /triggers/pubsub
  (validates topic pattern + reuses validate_trigger_target).
- AppPubsubPublish capability → script:write (seven-scope held).
- Dashboard Pub/Sub trigger form on the Triggers tab + list rendering.

publish_ephemeral stays deferred to v1.2. ~18 new tests (service
in-memory incl. transactional-rollback, shared matcher, bridge
encoding). No DB required for the suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 21:37:06 +02:00

525 lines
17 KiB
Rust

//! `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<HashMap<(AppId, String, DocId), DocRow>>,
}
#[async_trait]
impl DocsService for InMemoryDocs {
async fn create(
&self,
cx: &SdkCallCx,
collection: &str,
data: Value,
) -> Result<DocId, DocsError> {
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<Option<DocRow>, 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<Vec<DocRow>, 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<DocRow> = 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<Option<DocRow>, 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<bool, DocsError> {
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<DocsListPage, DocsError> {
let mut docs: Vec<DocRow> = 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<String, Value>) -> 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<String, Value>) -> 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<Engine> {
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<Engine>, 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<SdkCallCx>` 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"));
}