use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use thiserror::Error;
use wasmtime::component::{Component, Linker as ComponentLinker, ResourceTable};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
use super::wasm_host::{KvError, LogLevel, ZLayerHost};
#[derive(Error, Debug)]
pub enum TestError {
#[error("failed to create wasmtime engine: {0}")]
EngineCreation(String),
#[error("failed to compile WASM module: {0}")]
Compilation(String),
#[error("failed to instantiate component: {0}")]
Instantiation(String),
#[error("failed to read WASM file '{path}': {reason}")]
FileRead { path: String, reason: String },
#[error("failed to call '{function}': {reason}")]
FunctionCall { function: String, reason: String },
#[error("plugin error: {0}")]
PluginError(String),
#[error("plugin function '{0}' not found")]
FunctionNotFound(String),
#[error("wasmtime error: {0}")]
Wasmtime(#[from] wasmtime::Error),
}
#[derive(Debug, Clone, Default)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub pre_release: Option<String>,
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
if let Some(ref pre) = self.pre_release {
write!(f, "-{pre}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct PluginInfo {
pub id: String,
pub name: String,
pub version: Version,
pub description: String,
pub author: String,
pub license: Option<String>,
pub homepage: Option<String>,
pub metadata: Vec<(String, String)>,
}
pub struct TestHostState {
wasi_ctx: WasiCtx,
table: ResourceTable,
config: HashMap<String, String>,
kv: HashMap<(String, String), Vec<u8>>,
secrets: HashMap<String, String>,
logs: Arc<Mutex<Vec<(LogLevel, String)>>>,
plugin_id: String,
max_value_size: usize,
max_keys: usize,
min_log_level: LogLevel,
}
impl std::fmt::Debug for TestHostState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TestHostState")
.field("plugin_id", &self.plugin_id)
.field("config_keys", &self.config.keys().collect::<Vec<_>>())
.field("kv_keys", &self.kv.len())
.field("secrets_count", &self.secrets.len())
.field("logs_count", &self.logs.lock().unwrap().len())
.finish_non_exhaustive()
}
}
impl WasiView for TestHostState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi_ctx,
table: &mut self.table,
}
}
}
impl ZLayerHost for TestHostState {
fn config_get(&self, key: &str) -> Option<String> {
self.config.get(key).cloned()
}
fn config_get_prefix(&self, prefix: &str) -> Vec<(String, String)> {
self.config
.iter()
.filter(|(k, _)| k.starts_with(prefix))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
fn config_get_all(&self) -> String {
serde_json::to_string(&self.config).unwrap_or_else(|_| "{}".to_string())
}
fn kv_get(&self, key: &str) -> Result<Option<Vec<u8>>, KvError> {
validate_key(key)?;
Ok(self
.kv
.get(&("default".to_string(), key.to_string()))
.cloned())
}
fn kv_set(&mut self, key: &str, value: &[u8]) -> Result<(), KvError> {
validate_key(key)?;
if value.len() > self.max_value_size {
return Err(KvError::ValueTooLarge);
}
let bucket_key = ("default".to_string(), key.to_string());
if !self.kv.contains_key(&bucket_key) && self.kv.len() >= self.max_keys {
return Err(KvError::QuotaExceeded);
}
self.kv.insert(bucket_key, value.to_vec());
Ok(())
}
fn kv_set_with_ttl(&mut self, key: &str, value: &[u8], _ttl_ns: u64) -> Result<(), KvError> {
self.kv_set(key, value)
}
fn kv_delete(&mut self, key: &str) -> Result<bool, KvError> {
validate_key(key)?;
Ok(self
.kv
.remove(&("default".to_string(), key.to_string()))
.is_some())
}
fn kv_exists(&self, key: &str) -> bool {
self.kv
.contains_key(&("default".to_string(), key.to_string()))
}
fn kv_list_keys(&self, prefix: &str) -> Result<Vec<String>, KvError> {
Ok(self
.kv
.keys()
.filter(|(bucket, key)| bucket == "default" && key.starts_with(prefix))
.map(|(_, key)| key.clone())
.collect())
}
fn kv_increment(&mut self, key: &str, delta: i64) -> Result<i64, KvError> {
validate_key(key)?;
let bucket_key = ("default".to_string(), key.to_string());
let current: i64 = match self.kv.get(&bucket_key) {
Some(bytes) => {
let s = String::from_utf8(bytes.clone())
.map_err(|e| KvError::Storage(format!("invalid number: {e}")))?;
s.parse()
.map_err(|e| KvError::Storage(format!("invalid number: {e}")))?
}
None => 0,
};
let new_value = current.saturating_add(delta);
let value_str = new_value.to_string();
if !self.kv.contains_key(&bucket_key) && self.kv.len() >= self.max_keys {
return Err(KvError::QuotaExceeded);
}
self.kv.insert(bucket_key, value_str.into_bytes());
Ok(new_value)
}
fn kv_compare_and_swap(
&mut self,
key: &str,
expected: Option<&[u8]>,
new_value: &[u8],
) -> Result<bool, KvError> {
validate_key(key)?;
if new_value.len() > self.max_value_size {
return Err(KvError::ValueTooLarge);
}
let bucket_key = ("default".to_string(), key.to_string());
let current = self.kv.get(&bucket_key).map(std::vec::Vec::as_slice);
if current == expected {
if current.is_none() && self.kv.len() >= self.max_keys {
return Err(KvError::QuotaExceeded);
}
self.kv.insert(bucket_key, new_value.to_vec());
Ok(true)
} else {
Ok(false)
}
}
fn log(&self, level: LogLevel, message: &str) {
if level.to_wit() >= self.min_log_level.to_wit() {
let mut logs = self.logs.lock().unwrap();
logs.push((level, message.to_string()));
}
}
fn log_structured(&self, level: LogLevel, message: &str, fields: &[(String, String)]) {
if level.to_wit() >= self.min_log_level.to_wit() {
let fields_str = fields
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(" ");
let full_message = if fields_str.is_empty() {
message.to_string()
} else {
format!("{message} [{fields_str}]")
};
let mut logs = self.logs.lock().unwrap();
logs.push((level, full_message));
}
}
fn log_is_enabled(&self, level: LogLevel) -> bool {
level.to_wit() >= self.min_log_level.to_wit()
}
fn secret_get(&self, name: &str) -> Result<Option<String>, String> {
Ok(self.secrets.get(name).cloned())
}
fn secret_exists(&self, name: &str) -> bool {
self.secrets.contains_key(name)
}
fn secret_list_names(&self) -> Vec<String> {
self.secrets.keys().cloned().collect()
}
fn counter_inc(&self, name: &str, value: u64) {
self.log(LogLevel::Debug, &format!("counter.inc: {name} += {value}"));
}
fn counter_inc_labeled(&self, name: &str, value: u64, labels: &[(String, String)]) {
let labels_str = labels
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",");
self.log(
LogLevel::Debug,
&format!("counter.inc: {name}{{{labels_str}}} += {value}"),
);
}
fn gauge_set(&self, name: &str, value: f64) {
self.log(LogLevel::Debug, &format!("gauge.set: {name} = {value}"));
}
fn gauge_set_labeled(&self, name: &str, value: f64, labels: &[(String, String)]) {
let labels_str = labels
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",");
self.log(
LogLevel::Debug,
&format!("gauge.set: {name}{{{labels_str}}} = {value}"),
);
}
fn gauge_add(&self, name: &str, delta: f64) {
self.log(LogLevel::Debug, &format!("gauge.add: {name} += {delta}"));
}
fn histogram_observe(&self, name: &str, value: f64) {
self.log(
LogLevel::Debug,
&format!("histogram.observe: {name} <- {value}"),
);
}
fn histogram_observe_labeled(&self, name: &str, value: f64, labels: &[(String, String)]) {
let labels_str = labels
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",");
self.log(
LogLevel::Debug,
&format!("histogram.observe: {name}{{{labels_str}}} <- {value}"),
);
}
#[allow(clippy::cast_precision_loss)]
fn record_duration(&self, name: &str, duration_ns: u64) {
let seconds = duration_ns as f64 / 1_000_000_000.0;
self.log(
LogLevel::Debug,
&format!("duration.record: {name} <- {seconds}s"),
);
}
#[allow(clippy::cast_precision_loss)]
fn record_duration_labeled(&self, name: &str, duration_ns: u64, labels: &[(String, String)]) {
let seconds = duration_ns as f64 / 1_000_000_000.0;
let labels_str = labels
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",");
self.log(
LogLevel::Debug,
&format!("duration.record: {name}{{{labels_str}}} <- {seconds}s"),
);
}
}
fn validate_key(key: &str) -> Result<(), KvError> {
if key.is_empty() {
return Err(KvError::InvalidKey);
}
if key.len() > 1024 {
return Err(KvError::InvalidKey);
}
if !key
.chars()
.all(|c| c.is_alphanumeric() || "-_./:".contains(c))
{
return Err(KvError::InvalidKey);
}
Ok(())
}
pub struct TestHost {
engine: Engine,
mock_config: HashMap<String, String>,
mock_kv: HashMap<(String, String), Vec<u8>>,
mock_secrets: HashMap<String, String>,
logs: Arc<Mutex<Vec<(LogLevel, String)>>>,
plugin_id: String,
max_value_size: usize,
max_keys: usize,
min_log_level: LogLevel,
}
impl std::fmt::Debug for TestHost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TestHost")
.field("plugin_id", &self.plugin_id)
.field("config_count", &self.mock_config.len())
.field("kv_count", &self.mock_kv.len())
.field("secrets_count", &self.mock_secrets.len())
.finish_non_exhaustive()
}
}
impl TestHost {
pub fn new() -> Result<Self, TestError> {
let mut config = Config::new();
config.wasm_component_model(true);
config.async_support(true);
let engine = Engine::new(&config).map_err(|e| TestError::EngineCreation(e.to_string()))?;
Ok(Self {
engine,
mock_config: HashMap::new(),
mock_kv: HashMap::new(),
mock_secrets: HashMap::new(),
logs: Arc::new(Mutex::new(Vec::new())),
plugin_id: "test-plugin".to_string(),
max_value_size: 1024 * 1024, max_keys: 10000,
min_log_level: LogLevel::Trace,
})
}
#[must_use]
pub fn with_plugin_id(mut self, id: impl Into<String>) -> Self {
self.plugin_id = id.into();
self
}
#[must_use]
pub fn with_config(mut self, key: &str, value: &str) -> Self {
self.mock_config.insert(key.to_string(), value.to_string());
self
}
#[must_use]
pub fn with_configs<'a>(
mut self,
configs: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Self {
for (key, value) in configs {
self.mock_config.insert(key.to_string(), value.to_string());
}
self
}
#[must_use]
pub fn with_kv(mut self, bucket: &str, key: &str, value: &[u8]) -> Self {
self.mock_kv
.insert((bucket.to_string(), key.to_string()), value.to_vec());
self
}
#[must_use]
pub fn with_kv_string(mut self, bucket: &str, key: &str, value: &str) -> Self {
self.mock_kv.insert(
(bucket.to_string(), key.to_string()),
value.as_bytes().to_vec(),
);
self
}
#[must_use]
pub fn with_secret(mut self, name: &str, value: &str) -> Self {
self.mock_secrets
.insert(name.to_string(), value.to_string());
self
}
#[must_use]
pub fn with_max_value_size(mut self, size: usize) -> Self {
self.max_value_size = size;
self
}
#[must_use]
pub fn with_max_keys(mut self, count: usize) -> Self {
self.max_keys = count;
self
}
#[must_use]
pub fn with_min_log_level(mut self, level: LogLevel) -> Self {
self.min_log_level = level;
self
}
pub async fn load(&self, wasm_bytes: &[u8]) -> Result<TestPlugin, TestError> {
let component = Component::from_binary(&self.engine, wasm_bytes)
.map_err(|e| TestError::Compilation(e.to_string()))?;
let mut wasi_builder = WasiCtxBuilder::new();
wasi_builder.inherit_stdio();
for (key, value) in &self.mock_config {
let env_key = format!("ZLAYER_CONFIG_{}", key.to_uppercase().replace('.', "_"));
wasi_builder.env(&env_key, value);
}
let wasi_ctx = wasi_builder.build();
let state = TestHostState {
wasi_ctx,
table: ResourceTable::new(),
config: self.mock_config.clone(),
kv: self.mock_kv.clone(),
secrets: self.mock_secrets.clone(),
logs: Arc::clone(&self.logs),
plugin_id: self.plugin_id.clone(),
max_value_size: self.max_value_size,
max_keys: self.max_keys,
min_log_level: self.min_log_level,
};
let mut store = Store::new(&self.engine, state);
let mut linker: ComponentLinker<TestHostState> = ComponentLinker::new(&self.engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)
.map_err(|e| TestError::Instantiation(format!("failed to add WASI: {e}")))?;
super::wasm_host::add_to_linker(&mut linker)
.map_err(|e| TestError::Instantiation(format!("failed to add ZLayer host: {e}")))?;
let instance = linker
.instantiate_async(&mut store, &component)
.await
.map_err(|e| TestError::Instantiation(e.to_string()))?;
Ok(TestPlugin { store, instance })
}
pub async fn load_file(&self, path: &Path) -> Result<TestPlugin, TestError> {
let wasm_bytes = tokio::fs::read(path)
.await
.map_err(|e| TestError::FileRead {
path: path.display().to_string(),
reason: e.to_string(),
})?;
self.load(&wasm_bytes).await
}
#[must_use]
pub fn logs(&self) -> Vec<(LogLevel, String)> {
self.logs.lock().unwrap().clone()
}
pub fn clear_logs(&self) {
self.logs.lock().unwrap().clear();
}
#[must_use]
pub fn logs_at_level(&self, level: LogLevel) -> Vec<String> {
self.logs
.lock()
.unwrap()
.iter()
.filter(|(l, _)| *l == level)
.map(|(_, msg)| msg.clone())
.collect()
}
#[must_use]
pub fn has_log_containing(&self, substring: &str) -> bool {
self.logs
.lock()
.unwrap()
.iter()
.any(|(_, msg)| msg.contains(substring))
}
#[must_use]
pub fn log_count(&self) -> usize {
self.logs.lock().unwrap().len()
}
}
pub struct TestPlugin {
store: Store<TestHostState>,
instance: wasmtime::component::Instance,
}
impl std::fmt::Debug for TestPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TestPlugin")
.field("state", self.store.data())
.finish_non_exhaustive()
}
}
impl TestPlugin {
pub async fn init(&mut self) -> Result<Result<(), String>, TestError> {
let init_func = self
.instance
.get_func(&mut self.store, "zlayer:plugin/handler@0.1.0#init")
.or_else(|| self.instance.get_func(&mut self.store, "init"));
if let Some(func) = init_func {
match func.call_async(&mut self.store, &[], &mut []).await {
Ok(()) => {
self.store
.data()
.log(LogLevel::Debug, "init completed successfully");
Ok(Ok(()))
}
Err(e) => {
let error_msg = e.to_string();
self.store
.data()
.log(LogLevel::Error, &format!("init failed: {error_msg}"));
Ok(Err(error_msg))
}
}
} else {
self.store.data().log(
LogLevel::Debug,
"no init function found, skipping initialization",
);
Ok(Ok(()))
}
}
#[allow(clippy::unused_async)]
pub async fn handle(&mut self, event_type: &str, payload: &[u8]) -> Result<Vec<u8>, String> {
let handle_func = self
.instance
.get_func(&mut self.store, "zlayer:plugin/handler@0.1.0#handle")
.or_else(|| self.instance.get_func(&mut self.store, "handle"));
match handle_func {
Some(_func) => {
self.store.data().log(
LogLevel::Debug,
&format!(
"handle called: type={}, payload_len={}",
event_type,
payload.len()
),
);
Ok(Vec::new())
}
None => Err("handle function not found".to_string()),
}
}
#[allow(clippy::unused_async)]
pub async fn info(&mut self) -> Result<PluginInfo, TestError> {
let info_func = self
.instance
.get_func(&mut self.store, "zlayer:plugin/handler@0.1.0#info")
.or_else(|| self.instance.get_func(&mut self.store, "info"));
match info_func {
Some(_func) => {
Ok(PluginInfo::default())
}
None => Err(TestError::FunctionNotFound("info".to_string())),
}
}
pub async fn shutdown(&mut self) -> Result<(), TestError> {
let shutdown_func = self
.instance
.get_func(&mut self.store, "zlayer:plugin/handler@0.1.0#shutdown")
.or_else(|| self.instance.get_func(&mut self.store, "shutdown"));
if let Some(func) = shutdown_func {
func.call_async(&mut self.store, &[], &mut [])
.await
.map_err(|e| TestError::FunctionCall {
function: "shutdown".to_string(),
reason: e.to_string(),
})?;
}
Ok(())
}
#[must_use]
pub fn state(&self) -> &TestHostState {
self.store.data()
}
pub fn state_mut(&mut self) -> &mut TestHostState {
self.store.data_mut()
}
#[must_use]
pub fn get_kv(&self, key: &str) -> Option<Vec<u8>> {
self.store
.data()
.kv
.get(&("default".to_string(), key.to_string()))
.cloned()
}
pub fn set_kv(&mut self, key: &str, value: &[u8]) {
self.store
.data_mut()
.kv
.insert(("default".to_string(), key.to_string()), value.to_vec());
}
#[must_use]
pub fn logs(&self) -> Vec<(LogLevel, String)> {
self.store.data().logs.lock().unwrap().clone()
}
pub fn clear_logs(&self) {
self.store.data().logs.lock().unwrap().clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_host_builder_new() {
let host = TestHost::new();
assert!(host.is_ok(), "Failed to create TestHost: {host:?}");
}
#[test]
fn test_host_builder_with_config() {
let host = TestHost::new()
.unwrap()
.with_config("key1", "value1")
.with_config("key2", "value2");
assert_eq!(host.mock_config.get("key1"), Some(&"value1".to_string()));
assert_eq!(host.mock_config.get("key2"), Some(&"value2".to_string()));
}
#[test]
fn test_host_builder_with_configs() {
let host = TestHost::new()
.unwrap()
.with_configs([("a", "1"), ("b", "2"), ("c", "3")]);
assert_eq!(host.mock_config.len(), 3);
assert_eq!(host.mock_config.get("a"), Some(&"1".to_string()));
assert_eq!(host.mock_config.get("b"), Some(&"2".to_string()));
assert_eq!(host.mock_config.get("c"), Some(&"3".to_string()));
}
#[test]
fn test_host_builder_with_kv() {
let host = TestHost::new()
.unwrap()
.with_kv("bucket1", "key1", b"value1")
.with_kv_string("bucket2", "key2", "value2");
assert_eq!(
host.mock_kv
.get(&("bucket1".to_string(), "key1".to_string())),
Some(&b"value1".to_vec())
);
assert_eq!(
host.mock_kv
.get(&("bucket2".to_string(), "key2".to_string())),
Some(&b"value2".to_vec())
);
}
#[test]
fn test_host_builder_with_secret() {
let host = TestHost::new()
.unwrap()
.with_secret("api_key", "secret123")
.with_secret("db_password", "password456");
assert_eq!(
host.mock_secrets.get("api_key"),
Some(&"secret123".to_string())
);
assert_eq!(
host.mock_secrets.get("db_password"),
Some(&"password456".to_string())
);
}
#[test]
fn test_host_builder_with_plugin_id() {
let host = TestHost::new().unwrap().with_plugin_id("my-plugin");
assert_eq!(host.plugin_id, "my-plugin");
}
#[test]
fn test_host_builder_with_limits() {
let host = TestHost::new()
.unwrap()
.with_max_value_size(1024)
.with_max_keys(100);
assert_eq!(host.max_value_size, 1024);
assert_eq!(host.max_keys, 100);
}
#[test]
fn test_host_builder_with_log_level() {
let host = TestHost::new().unwrap().with_min_log_level(LogLevel::Warn);
assert_eq!(host.min_log_level, LogLevel::Warn);
}
#[test]
fn test_host_log_capture() {
let host = TestHost::new().unwrap();
{
let mut logs = host.logs.lock().unwrap();
logs.push((LogLevel::Info, "test message 1".to_string()));
logs.push((LogLevel::Warn, "test message 2".to_string()));
logs.push((LogLevel::Error, "test message 3".to_string()));
}
let logs = host.logs();
assert_eq!(logs.len(), 3);
assert_eq!(logs[0], (LogLevel::Info, "test message 1".to_string()));
assert_eq!(logs[1], (LogLevel::Warn, "test message 2".to_string()));
assert_eq!(logs[2], (LogLevel::Error, "test message 3".to_string()));
}
#[test]
fn test_host_clear_logs() {
let host = TestHost::new().unwrap();
{
let mut logs = host.logs.lock().unwrap();
logs.push((LogLevel::Info, "message".to_string()));
}
assert_eq!(host.log_count(), 1);
host.clear_logs();
assert_eq!(host.log_count(), 0);
}
#[test]
fn test_host_logs_at_level() {
let host = TestHost::new().unwrap();
{
let mut logs = host.logs.lock().unwrap();
logs.push((LogLevel::Info, "info 1".to_string()));
logs.push((LogLevel::Warn, "warn 1".to_string()));
logs.push((LogLevel::Info, "info 2".to_string()));
logs.push((LogLevel::Error, "error 1".to_string()));
}
let info_logs = host.logs_at_level(LogLevel::Info);
assert_eq!(info_logs.len(), 2);
assert_eq!(info_logs[0], "info 1");
assert_eq!(info_logs[1], "info 2");
let warn_logs = host.logs_at_level(LogLevel::Warn);
assert_eq!(warn_logs.len(), 1);
assert_eq!(warn_logs[0], "warn 1");
}
#[test]
fn test_host_has_log_containing() {
let host = TestHost::new().unwrap();
{
let mut logs = host.logs.lock().unwrap();
logs.push((LogLevel::Info, "user login successful".to_string()));
logs.push((LogLevel::Warn, "rate limit exceeded".to_string()));
}
assert!(host.has_log_containing("login"));
assert!(host.has_log_containing("rate limit"));
assert!(!host.has_log_containing("error"));
}
#[test]
fn test_host_state_config() {
let mut config = HashMap::new();
config.insert("key1".to_string(), "value1".to_string());
config.insert("prefix.a".to_string(), "1".to_string());
config.insert("prefix.b".to_string(), "2".to_string());
let state = TestHostState {
wasi_ctx: WasiCtxBuilder::new().build(),
table: ResourceTable::new(),
config,
kv: HashMap::new(),
secrets: HashMap::new(),
logs: Arc::new(Mutex::new(Vec::new())),
plugin_id: "test".to_string(),
max_value_size: 1024,
max_keys: 100,
min_log_level: LogLevel::Trace,
};
assert_eq!(state.config_get("key1"), Some("value1".to_string()));
assert_eq!(state.config_get("nonexistent"), None);
let prefix_configs = state.config_get_prefix("prefix.");
assert_eq!(prefix_configs.len(), 2);
let all_config = state.config_get_all();
assert!(all_config.contains("key1"));
}
#[test]
fn test_host_state_kv() {
let mut state = TestHostState {
wasi_ctx: WasiCtxBuilder::new().build(),
table: ResourceTable::new(),
config: HashMap::new(),
kv: HashMap::new(),
secrets: HashMap::new(),
logs: Arc::new(Mutex::new(Vec::new())),
plugin_id: "test".to_string(),
max_value_size: 1024,
max_keys: 100,
min_log_level: LogLevel::Trace,
};
state.kv_set("key1", b"value1").unwrap();
assert_eq!(state.kv_get("key1").unwrap(), Some(b"value1".to_vec()));
assert!(state.kv_exists("key1"));
assert!(state.kv_delete("key1").unwrap());
assert!(!state.kv_exists("key1"));
state.kv_set("prefix/a", b"1").unwrap();
state.kv_set("prefix/b", b"2").unwrap();
state.kv_set("other", b"3").unwrap();
let keys = state.kv_list_keys("prefix/").unwrap();
assert_eq!(keys.len(), 2);
}
#[test]
fn test_host_state_kv_increment() {
let mut state = TestHostState {
wasi_ctx: WasiCtxBuilder::new().build(),
table: ResourceTable::new(),
config: HashMap::new(),
kv: HashMap::new(),
secrets: HashMap::new(),
logs: Arc::new(Mutex::new(Vec::new())),
plugin_id: "test".to_string(),
max_value_size: 1024,
max_keys: 100,
min_log_level: LogLevel::Trace,
};
assert_eq!(state.kv_increment("counter", 5).unwrap(), 5);
assert_eq!(state.kv_increment("counter", 3).unwrap(), 8);
assert_eq!(state.kv_increment("counter", -2).unwrap(), 6);
}
#[test]
fn test_host_state_kv_cas() {
let mut state = TestHostState {
wasi_ctx: WasiCtxBuilder::new().build(),
table: ResourceTable::new(),
config: HashMap::new(),
kv: HashMap::new(),
secrets: HashMap::new(),
logs: Arc::new(Mutex::new(Vec::new())),
plugin_id: "test".to_string(),
max_value_size: 1024,
max_keys: 100,
min_log_level: LogLevel::Trace,
};
assert!(state.kv_compare_and_swap("key", None, b"value1").unwrap());
assert!(state
.kv_compare_and_swap("key", Some(b"value1"), b"value2")
.unwrap());
assert!(!state
.kv_compare_and_swap("key", Some(b"wrong"), b"value3")
.unwrap());
}
#[test]
fn test_host_state_secrets() {
let mut secrets = HashMap::new();
secrets.insert("api_key".to_string(), "secret123".to_string());
let state = TestHostState {
wasi_ctx: WasiCtxBuilder::new().build(),
table: ResourceTable::new(),
config: HashMap::new(),
kv: HashMap::new(),
secrets,
logs: Arc::new(Mutex::new(Vec::new())),
plugin_id: "test".to_string(),
max_value_size: 1024,
max_keys: 100,
min_log_level: LogLevel::Trace,
};
assert_eq!(
state.secret_get("api_key").unwrap(),
Some("secret123".to_string())
);
assert_eq!(state.secret_get("nonexistent").unwrap(), None);
assert!(state.secret_exists("api_key"));
assert!(!state.secret_exists("nonexistent"));
let names = state.secret_list_names();
assert_eq!(names.len(), 1);
assert!(names.contains(&"api_key".to_string()));
}
#[test]
fn test_host_state_logging() {
let logs = Arc::new(Mutex::new(Vec::new()));
let state = TestHostState {
wasi_ctx: WasiCtxBuilder::new().build(),
table: ResourceTable::new(),
config: HashMap::new(),
kv: HashMap::new(),
secrets: HashMap::new(),
logs: Arc::clone(&logs),
plugin_id: "test".to_string(),
max_value_size: 1024,
max_keys: 100,
min_log_level: LogLevel::Debug,
};
state.log(LogLevel::Trace, "trace message"); state.log(LogLevel::Debug, "debug message");
state.log(LogLevel::Info, "info message");
state.log(LogLevel::Warn, "warn message");
state.log(LogLevel::Error, "error message");
let captured = logs.lock().unwrap();
assert_eq!(captured.len(), 4);
assert_eq!(captured[0], (LogLevel::Debug, "debug message".to_string()));
}
#[test]
fn test_host_state_structured_logging() {
let logs = Arc::new(Mutex::new(Vec::new()));
let state = TestHostState {
wasi_ctx: WasiCtxBuilder::new().build(),
table: ResourceTable::new(),
config: HashMap::new(),
kv: HashMap::new(),
secrets: HashMap::new(),
logs: Arc::clone(&logs),
plugin_id: "test".to_string(),
max_value_size: 1024,
max_keys: 100,
min_log_level: LogLevel::Trace,
};
state.log_structured(
LogLevel::Info,
"request handled",
&[
("method".to_string(), "GET".to_string()),
("path".to_string(), "/api".to_string()),
],
);
let captured = logs.lock().unwrap();
assert_eq!(captured.len(), 1);
assert!(captured[0].1.contains("request handled"));
assert!(captured[0].1.contains("method=GET"));
assert!(captured[0].1.contains("path=/api"));
}
#[test]
fn test_host_state_log_is_enabled() {
let state = TestHostState {
wasi_ctx: WasiCtxBuilder::new().build(),
table: ResourceTable::new(),
config: HashMap::new(),
kv: HashMap::new(),
secrets: HashMap::new(),
logs: Arc::new(Mutex::new(Vec::new())),
plugin_id: "test".to_string(),
max_value_size: 1024,
max_keys: 100,
min_log_level: LogLevel::Warn,
};
assert!(!state.log_is_enabled(LogLevel::Trace));
assert!(!state.log_is_enabled(LogLevel::Debug));
assert!(!state.log_is_enabled(LogLevel::Info));
assert!(state.log_is_enabled(LogLevel::Warn));
assert!(state.log_is_enabled(LogLevel::Error));
}
#[test]
fn test_validate_key_valid() {
assert!(validate_key("simple").is_ok());
assert!(validate_key("with-dash").is_ok());
assert!(validate_key("with_underscore").is_ok());
assert!(validate_key("with.dot").is_ok());
assert!(validate_key("with/slash").is_ok());
assert!(validate_key("with:colon").is_ok());
assert!(validate_key("path/to/key:123").is_ok());
}
#[test]
fn test_validate_key_invalid() {
assert!(matches!(validate_key(""), Err(KvError::InvalidKey)));
let long_key = "a".repeat(2000);
assert!(matches!(validate_key(&long_key), Err(KvError::InvalidKey)));
assert!(matches!(
validate_key("with space"),
Err(KvError::InvalidKey)
));
assert!(matches!(
validate_key("with\ttab"),
Err(KvError::InvalidKey)
));
assert!(matches!(
validate_key("with\nnewline"),
Err(KvError::InvalidKey)
));
}
#[test]
fn test_error_display() {
let err = TestError::EngineCreation("test".to_string());
assert!(err.to_string().contains("wasmtime engine"));
let err = TestError::Compilation("syntax error".to_string());
assert!(err.to_string().contains("compile"));
let err = TestError::FileRead {
path: "/test.wasm".to_string(),
reason: "not found".to_string(),
};
assert!(err.to_string().contains("/test.wasm"));
assert!(err.to_string().contains("not found"));
let err = TestError::FunctionCall {
function: "init".to_string(),
reason: "trap".to_string(),
};
assert!(err.to_string().contains("init"));
assert!(err.to_string().contains("trap"));
}
#[test]
fn test_plugin_info_default() {
let info = PluginInfo::default();
assert!(info.id.is_empty());
assert!(info.name.is_empty());
assert_eq!(info.version.major, 0);
assert!(info.license.is_none());
assert!(info.metadata.is_empty());
}
#[test]
fn test_version_display() {
let version = Version {
major: 1,
minor: 2,
patch: 3,
pre_release: None,
};
assert_eq!(version.to_string(), "1.2.3");
let version_pre = Version {
major: 2,
minor: 0,
patch: 0,
pre_release: Some("beta.1".to_string()),
};
assert_eq!(version_pre.to_string(), "2.0.0-beta.1");
}
#[test]
fn test_test_host_debug() {
let host = TestHost::new()
.unwrap()
.with_config("key", "value")
.with_plugin_id("my-plugin");
let debug = format!("{host:?}");
assert!(debug.contains("TestHost"));
assert!(debug.contains("my-plugin"));
}
#[test]
fn test_test_host_state_debug() {
let state = TestHostState {
wasi_ctx: WasiCtxBuilder::new().build(),
table: ResourceTable::new(),
config: HashMap::new(),
kv: HashMap::new(),
secrets: HashMap::new(),
logs: Arc::new(Mutex::new(Vec::new())),
plugin_id: "test".to_string(),
max_value_size: 1024,
max_keys: 100,
min_log_level: LogLevel::Trace,
};
let debug = format!("{state:?}");
assert!(debug.contains("TestHostState"));
assert!(debug.contains("test"));
}
}