//! `files::` SDK bridge integration tests — runs a real Rhai engine //! against an in-memory `FilesService` impl. Mirrors `tests/sdk_kv.rs`: //! `tokio::task::spawn_blocking` so the bridge's `block_on` has a //! reachable runtime. Exercises the actual Rhai surface — blob in/out, //! the metadata map shape, and the missing-required-field throw. use std::collections::BTreeMap; use std::sync::Arc; use async_trait::async_trait; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_shared::{ AppId, ExecutionId, FileMeta, FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services, }; use serde_json::{json, Value}; use tokio::sync::Mutex; use uuid::Uuid; #[derive(Default)] struct InMemoryFiles { #[allow(clippy::type_complexity)] data: Mutex)>>, } /// The in-memory fake doesn't exercise the real checksum path (the /// `FsFilesRepo` tempdir tests in manager-core cover SHA-256); a stable /// placeholder keeps the metadata map non-empty. fn fake_checksum(bytes: &[u8]) -> String { format!("len-{}", bytes.len()) } #[async_trait] impl FilesService for InMemoryFiles { async fn create( &self, cx: &SdkCallCx, collection: &str, new: NewFile, ) -> Result { if collection.is_empty() { return Err(FilesError::InvalidCollection("empty".into())); } new.validate(100 * 1024 * 1024)?; let id = Uuid::new_v4(); let now = chrono::Utc::now(); let meta = FileMeta { id, collection: collection.to_string(), name: new.name.clone(), content_type: new.content_type.clone(), size: new.data.len() as u64, checksum: fake_checksum(&new.data), created_at: now, updated_at: now, }; self.data .lock() .await .insert((cx.app_id, collection.to_string(), id), (meta, new.data)); Ok(id) } async fn head( &self, cx: &SdkCallCx, collection: &str, id: &str, ) -> Result, FilesError> { let Ok(uuid) = Uuid::parse_str(id) else { return Ok(None); }; Ok(self .data .lock() .await .get(&(cx.app_id, collection.to_string(), uuid)) .map(|(m, _)| m.clone())) } async fn get( &self, cx: &SdkCallCx, collection: &str, id: &str, ) -> Result>, FilesError> { let Ok(uuid) = Uuid::parse_str(id) else { return Ok(None); }; Ok(self .data .lock() .await .get(&(cx.app_id, collection.to_string(), uuid)) .map(|(_, b)| b.clone())) } async fn update( &self, cx: &SdkCallCx, collection: &str, id: &str, upd: FileUpdate, ) -> Result<(), FilesError> { upd.validate(100 * 1024 * 1024)?; let Ok(uuid) = Uuid::parse_str(id) else { return Err(FilesError::NotFound); }; let mut data = self.data.lock().await; let key = (cx.app_id, collection.to_string(), uuid); let Some((meta, _)) = data.get(&key).cloned() else { return Err(FilesError::NotFound); }; let mut meta = meta; if let Some(n) = upd.name { meta.name = n; } if let Some(ct) = upd.content_type { meta.content_type = ct; } meta.size = upd.data.len() as u64; meta.checksum = fake_checksum(&upd.data); data.insert(key, (meta, upd.data)); Ok(()) } async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result { let Ok(uuid) = Uuid::parse_str(id) else { return Ok(false); }; Ok(self .data .lock() .await .remove(&(cx.app_id, collection.to_string(), uuid)) .is_some()) } async fn list( &self, cx: &SdkCallCx, collection: &str, _cursor: Option<&str>, _limit: u32, ) -> Result { let data = self.data.lock().await; let files: Vec = data .iter() .filter(|((a, c, _), _)| *a == cx.app_id && c == collection) .map(|(_, (m, _))| m.clone()) .collect(); Ok(FilesListPage { files, next_cursor: None, }) } } fn make_engine() -> Arc { let services = Services::new( Arc::new(NoopKvService), Arc::new(NoopDocsService), Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), Arc::new(NoopModuleSource), Arc::new(NoopHttpService), Arc::new(InMemoryFiles::default()), ); 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: "files-test".into(), invocation_type: InvocationType::Http, path: "/files-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 } async fn run_script_err(engine: Arc, src: &str, req: ExecRequest) -> String { let src = src.to_string(); let res = tokio::task::spawn_blocking(move || engine.execute(&src, req)) .await .expect("spawn_blocking should not panic"); format!("{:?}", res.expect_err("script should error")) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn files_create_get_round_trip_via_blob() { let engine = make_engine(); let app = AppId::new(); // base64("hello") = "aGVsbG8="; decode → blob; create; get back; encode. let src = r#" let c = files::collection("avatars"); let data = base64::decode("aGVsbG8="); let id = c.create(#{ name: "a.txt", content_type: "text/plain", data: data }); let back = c.get(id); base64::encode(back) "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!("aGVsbG8=")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn files_head_returns_metadata_map() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = files::collection("avatars"); let data = base64::decode("aGVsbG8="); let id = c.create(#{ name: "a.txt", content_type: "text/plain", data: data }); let meta = c.head(id); #{ name: meta.name, content_type: meta.content_type, size: meta.size, has_checksum: meta.checksum != () } "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!( body, json!({ "name": "a.txt", "content_type": "text/plain", "size": 5, "has_checksum": true }) ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn files_get_and_head_missing_return_unit() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = files::collection("avatars"); let g = c.get("00000000-0000-0000-0000-000000000000"); let h = c.head("00000000-0000-0000-0000-000000000000"); #{ g: g == (), h: h == () } "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!({ "g": true, "h": true })); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn files_update_then_delete() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = files::collection("avatars"); let id = c.create(#{ name: "a", content_type: "text/plain", data: base64::decode("YQ==") }); c.update(id, #{ data: base64::decode("YmM=") }); // "bc" let after = base64::encode(c.get(id)); let removed = c.delete(id); let gone = c.delete(id); #{ after: after, removed: removed, gone: gone } "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!( body, json!({ "after": "YmM=", "removed": true, "gone": false }) ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn files_create_missing_data_throws_naming_field() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = files::collection("avatars"); c.create(#{ name: "a", content_type: "text/plain" }) "#; let err = run_script_err(engine, src, baseline_request(app)).await; assert!( err.contains("data"), "error should name the missing field: {err}" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn files_create_missing_name_throws_naming_field() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = files::collection("avatars"); c.create(#{ content_type: "text/plain", data: base64::decode("YQ==") }) "#; let err = run_script_err(engine, src, baseline_request(app)).await; assert!( err.contains("name"), "error should name the missing field: {err}" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn files_empty_collection_name_throws() { let engine = make_engine(); let app = AppId::new(); let err = run_script_err(engine, r#"files::collection("")"#, baseline_request(app)).await; assert!(err.to_lowercase().contains("empty"), "got {err}"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn files_list_returns_files_array() { let engine = make_engine(); let app = AppId::new(); let src = r#" let c = files::collection("avatars"); c.create(#{ name: "a", content_type: "text/plain", data: base64::decode("YQ==") }); c.create(#{ name: "b", content_type: "text/plain", data: base64::decode("Yg==") }); let page = c.list(); page.files.len() "#; let body = run_script(engine, src, baseline_request(app)).await; assert_eq!(body, json!(2)); }