Filesystem-backed blob storage as the fifth concrete trigger kind.
- `files::collection(c).{create,head,get,update,delete,list}` Rhai SDK
(blob in/out; metadata maps; missing-field throws naming the field).
- `FilesService` trait in picloud-shared; `FsFilesRepo` (atomic
write: temp→fsync→rename→fsync-dir→DB; single-pass SHA-256;
checksum-verified reads → Corrupted) + `FilesServiceImpl` in
manager-core. Metadata in Postgres (0018), bytes on disk under
PICLOUD_FILES_ROOT with 0o700 shard dirs.
- `files:*` trigger kind via the Layout-E pattern (0019: widen both
CHECKs + files_trigger_details), TriggerEvent::Files (metadata only,
no bytes), emit_files fan-out, dispatcher arm, admin endpoint
POST /triggers/files (reuses validate_trigger_target).
- AppFilesRead/AppFilesWrite capabilities → script:read/script:write
(seven-scope commitment held). AppPubsubPublish reserved for v1.1.6.
- Admin files API (list + delete) + dashboard Files view per app.
Cross-app isolation keyed on cx.app_id at every layer. ~45 new tests
(service in-memory, fs tempdir, bridge integration). No DB required
for the suite. publish_ephemeral and the orphan sweep stay deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
334 lines
11 KiB
Rust
334 lines
11 KiB
Rust
//! `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<BTreeMap<(AppId, String, Uuid), (FileMeta, Vec<u8>)>>,
|
|
}
|
|
|
|
/// 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<Uuid, FilesError> {
|
|
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<Option<FileMeta>, 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<Option<Vec<u8>>, 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<bool, FilesError> {
|
|
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<FilesListPage, FilesError> {
|
|
let data = self.data.lock().await;
|
|
let files: Vec<FileMeta> = 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<Engine> {
|
|
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<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
|
|
}
|
|
|
|
async fn run_script_err(engine: Arc<Engine>, 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));
|
|
}
|