From bc69699c020f5f9dd407b00ed169a3fd387e503d Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 4 Mar 2026 14:15:11 -0600 Subject: [PATCH] test: add tests for untested lua modules --- src/lua/context.rs | 51 +++++++++ src/lua/db_api.rs | 118 ++++++++++++++++++++ src/lua/execute.rs | 122 +++++++++++++++++++++ src/lua/mod.rs | 2 +- src/lua/record.rs | 123 ++++++++++++++++++++- tests/lua_db_api.rs | 254 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 tests/lua_db_api.rs diff --git a/src/lua/context.rs b/src/lua/context.rs index f718ef8..34d3e43 100644 --- a/src/lua/context.rs +++ b/src/lua/context.rs @@ -90,6 +90,57 @@ mod tests { assert_eq!(rec.get::("name").unwrap(), "Test Game"); } + #[test] + fn procedure_context_sets_all_globals() { + let lua = create_sandbox().unwrap(); + let input = json!({"key": "val"}); + set_procedure_context( + &lua, + "com.example.doThing", + &input, + "did:plc:test", + "com.example.thing", + ) + .unwrap(); + + let globals = lua.globals(); + assert_eq!( + globals.get::("method").unwrap(), + "com.example.doThing" + ); + assert_eq!(globals.get::("caller_did").unwrap(), "did:plc:test"); + assert_eq!( + globals.get::("collection").unwrap(), + "com.example.thing" + ); + + let input_table: mlua::Table = globals.get("input").unwrap(); + assert_eq!(input_table.get::("key").unwrap(), "val"); + } + + #[test] + fn query_context_sets_all_globals() { + let lua = create_sandbox().unwrap(); + let mut params = HashMap::new(); + params.insert("limit".to_string(), "10".to_string()); + params.insert("cursor".to_string(), "abc".to_string()); + set_query_context(&lua, "com.example.listThings", ¶ms, "com.example.thing").unwrap(); + + let globals = lua.globals(); + assert_eq!( + globals.get::("method").unwrap(), + "com.example.listThings" + ); + assert_eq!( + globals.get::("collection").unwrap(), + "com.example.thing" + ); + + let params_table: mlua::Table = globals.get("params").unwrap(); + assert_eq!(params_table.get::("limit").unwrap(), "10"); + assert_eq!(params_table.get::("cursor").unwrap(), "abc"); + } + #[test] fn hook_context_record_nil_on_delete() { let lua = create_sandbox().unwrap(); diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs index a885b3f..7e8b5f3 100644 --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -402,3 +402,121 @@ pub fn register_db_api(lua: &Lua, state: Arc) -> LuaResult<()> { lua.globals().set("db", db_table)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::lexicon::LexiconRegistry; + use tokio::sync::watch; + + fn test_state() -> AppState { + let config = Config { + host: "127.0.0.1".into(), + port: 3000, + database_url: String::new(), + aip_url: String::new(), + aip_public_url: String::new(), + tap_url: String::new(), + tap_admin_password: None, + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + event_log_retention_days: 30, + }; + let (tx, _) = watch::channel(vec![]); + AppState { + config, + http: reqwest::Client::new(), + db: sqlx::PgPool::connect_lazy("postgres://localhost/fake").unwrap(), + lexicons: LexiconRegistry::new(), + collections_tx: tx, + } + } + + fn setup(state: &AppState) -> Lua { + let lua = Lua::new(); + register_db_api(&lua, Arc::new(state.clone())).unwrap(); + lua + } + + #[tokio::test] + async fn raw_rejects_non_select() { + let state = test_state(); + let lua = setup(&state); + let result: Result = lua + .load(r#"return db.raw("DELETE FROM records")"#) + .eval_async() + .await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("only supports SELECT"), + "expected SELECT-only error, got: {err}" + ); + } + + #[tokio::test] + async fn raw_allows_select() { + let state = test_state(); + let lua = setup(&state); + let result: Result = + lua.load(r#"return db.raw("SELECT 1")"#).eval_async().await; + // Should fail with a DB connection error, NOT a validation error + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + !err.contains("only supports SELECT"), + "should have passed validation but got: {err}" + ); + } + + #[tokio::test] + async fn query_rejects_invalid_sort_field() { + let state = test_state(); + let lua = setup(&state); + let result: Result = lua + .load(r#"return db.query({ collection = "test", sort = "name; DROP TABLE" })"#) + .eval_async() + .await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("invalid sort field"), + "expected sort field error, got: {err}" + ); + } + + #[tokio::test] + async fn query_rejects_invalid_sort_direction() { + let state = test_state(); + let lua = setup(&state); + let result: Result = lua + .load(r#"return db.query({ collection = "test", sortDirection = "sideways" })"#) + .eval_async() + .await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("invalid sortDirection"), + "expected sortDirection error, got: {err}" + ); + } + + #[tokio::test] + async fn query_accepts_valid_sort_direction() { + let state = test_state(); + let lua = setup(&state); + let result: Result = lua + .load(r#"return db.query({ collection = "test", sortDirection = "asc" })"#) + .eval_async() + .await; + // Should fail with a DB connection error, NOT a validation error + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + !err.contains("invalid sortDirection"), + "should have passed validation but got: {err}" + ); + } +} diff --git a/src/lua/execute.rs b/src/lua/execute.rs index 8ab0794..00fa926 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -662,3 +662,125 @@ async fn run_hook_once(event: &HookEvent<'_>) -> Result<(), String> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::lexicon::LexiconRegistry; + use serde_json::json; + use tokio::sync::watch; + + fn test_state() -> AppState { + let config = Config { + host: "127.0.0.1".into(), + port: 3000, + database_url: String::new(), + aip_url: String::new(), + aip_public_url: String::new(), + tap_url: String::new(), + tap_admin_password: None, + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + event_log_retention_days: 30, + }; + let (tx, _) = watch::channel(vec![]); + AppState { + config, + http: reqwest::Client::new(), + db: sqlx::PgPool::connect_lazy("postgres://localhost/fake").unwrap(), + lexicons: LexiconRegistry::new(), + collections_tx: tx, + } + } + + fn make_event<'a>( + state: &'a AppState, + script: &'a str, + action: &'a str, + record: Option<&'a Value>, + ) -> HookEvent<'a> { + HookEvent { + state, + lexicon_id: "test.lexicon", + script, + action, + uri: "at://did:plc:test/test.collection/rkey1", + did: "did:plc:test", + collection: "test.collection", + rkey: "rkey1", + record, + } + } + + #[tokio::test] + async fn hook_runs_simple_script() { + let state = test_state(); + let event = make_event(&state, "function handle() end", "create", None); + let result = run_hook_once(&event).await; + assert!(result.is_ok(), "expected Ok, got: {:?}", result); + } + + #[tokio::test] + async fn hook_fails_on_missing_handle() { + let state = test_state(); + let event = make_event(&state, "function other() end", "create", None); + let result = run_hook_once(&event).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("handle"), "expected handle error, got: {err}"); + } + + #[tokio::test] + async fn hook_fails_on_syntax_error() { + let state = test_state(); + let event = make_event(&state, "function handle(", "create", None); + let result = run_hook_once(&event).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn hook_has_access_to_context_globals() { + let state = test_state(); + let script = r#" + function handle() + if action ~= "create" then error("wrong action: " .. tostring(action)) end + if uri ~= "at://did:plc:test/test.collection/rkey1" then error("wrong uri") end + if did ~= "did:plc:test" then error("wrong did") end + if collection ~= "test.collection" then error("wrong collection") end + if rkey ~= "rkey1" then error("wrong rkey") end + end + "#; + let event = make_event(&state, script, "create", None); + let result = run_hook_once(&event).await; + assert!(result.is_ok(), "expected Ok, got: {:?}", result); + } + + #[tokio::test] + async fn hook_has_access_to_record() { + let state = test_state(); + let record = json!({"name": "Test"}); + let script = r#" + function handle() + if record.name ~= "Test" then error("wrong name: " .. tostring(record.name)) end + end + "#; + let event = make_event(&state, script, "create", Some(&record)); + let result = run_hook_once(&event).await; + assert!(result.is_ok(), "expected Ok, got: {:?}", result); + } + + #[tokio::test] + async fn hook_record_nil_on_delete() { + let state = test_state(); + let script = r#" + function handle() + if record ~= nil then error("expected nil record") end + end + "#; + let event = make_event(&state, script, "delete", None); + let result = run_hook_once(&event).await; + assert!(result.is_ok(), "expected Ok, got: {:?}", result); + } +} diff --git a/src/lua/mod.rs b/src/lua/mod.rs index 37e3c20..b592a0c 100644 --- a/src/lua/mod.rs +++ b/src/lua/mod.rs @@ -1,5 +1,5 @@ mod context; -mod db_api; +pub mod db_api; mod execute; mod http_api; mod record; diff --git a/src/lua/record.rs b/src/lua/record.rs index a2dcdcb..ad1894f 100644 --- a/src/lua/record.rs +++ b/src/lua/record.rs @@ -801,7 +801,7 @@ fn populate_defaults(lua: &Lua, table: &mlua::Table, schema: &mlua::Table) -> Lu } /// Serialize a Record table to serde_json::Value, stripping _-prefixed keys, -/// filtering to only schema-defined properties, and injecting $type. +/// filtering to only schema-defined properties, and injecting `$type`. fn extract_record_data(lua: &Lua, table: &mlua::Table, collection: &str) -> LuaResult { // Build the set of allowed property names from the schema (if available). // When a schema is present, only fields listed in `properties` are included. @@ -838,3 +838,124 @@ fn extract_record_data(lua: &Lua, table: &mlua::Table, collection: &str) -> LuaR } Ok(data) } + +#[cfg(test)] +mod tests { + use super::*; + use mlua::Lua; + + #[test] + fn validate_required_fields_passes_when_present() { + let lua = Lua::new(); + let table = lua.create_table().unwrap(); + table.raw_set("name", "test").unwrap(); + table.raw_set("count", 1).unwrap(); + + let schema = lua.create_table().unwrap(); + let required = lua.create_sequence_from(["name", "count"]).unwrap(); + schema.raw_set("required", required).unwrap(); + + assert!(validate_required_fields(&table, &schema).is_ok()); + } + + #[test] + fn validate_required_fields_fails_when_missing() { + let lua = Lua::new(); + let table = lua.create_table().unwrap(); + table.raw_set("name", "test").unwrap(); + + let schema = lua.create_table().unwrap(); + let required = lua.create_sequence_from(["name", "count"]).unwrap(); + schema.raw_set("required", required).unwrap(); + + let result = validate_required_fields(&table, &schema); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("count"), + "expected error about 'count', got: {err}" + ); + } + + #[test] + fn populate_defaults_fills_missing_fields() { + let lua = Lua::new(); + let table = lua.create_table().unwrap(); + + let schema = lua.create_table().unwrap(); + let properties = lua.create_table().unwrap(); + let status_prop = lua.create_table().unwrap(); + status_prop.raw_set("default", "draft").unwrap(); + properties.raw_set("status", status_prop).unwrap(); + schema.raw_set("properties", properties).unwrap(); + + populate_defaults(&lua, &table, &schema).unwrap(); + assert_eq!(table.raw_get::("status").unwrap(), "draft"); + } + + #[test] + fn populate_defaults_does_not_overwrite_existing() { + let lua = Lua::new(); + let table = lua.create_table().unwrap(); + table.raw_set("status", "published").unwrap(); + + let schema = lua.create_table().unwrap(); + let properties = lua.create_table().unwrap(); + let status_prop = lua.create_table().unwrap(); + status_prop.raw_set("default", "draft").unwrap(); + properties.raw_set("status", status_prop).unwrap(); + schema.raw_set("properties", properties).unwrap(); + + populate_defaults(&lua, &table, &schema).unwrap(); + assert_eq!(table.raw_get::("status").unwrap(), "published"); + } + + #[test] + fn extract_record_data_strips_internal_fields() { + let lua = Lua::new(); + let table = lua.create_table().unwrap(); + table.raw_set("_collection", "col").unwrap(); + table.raw_set("_uri", "at://did:plc:test/col/rkey").unwrap(); + table.raw_set("_schema", mlua::Value::Nil).unwrap(); + table.raw_set("name", "test").unwrap(); + + let data = extract_record_data(&lua, &table, "com.example.thing").unwrap(); + let obj = data.as_object().unwrap(); + assert!(obj.contains_key("name")); + assert!(obj.contains_key("$type")); + assert!(!obj.contains_key("_collection")); + assert!(!obj.contains_key("_uri")); + } + + #[test] + fn extract_record_data_filters_to_schema_properties() { + let lua = Lua::new(); + let table = lua.create_table().unwrap(); + table.raw_set("name", "test").unwrap(); + table.raw_set("extra", "junk").unwrap(); + + // Build a schema with only "name" in properties + let schema = lua.create_table().unwrap(); + let properties = lua.create_table().unwrap(); + let name_prop = lua.create_table().unwrap(); + properties.raw_set("name", name_prop).unwrap(); + schema.raw_set("properties", properties).unwrap(); + table.raw_set("_schema", schema).unwrap(); + + let data = extract_record_data(&lua, &table, "com.example.thing").unwrap(); + let obj = data.as_object().unwrap(); + assert!(obj.contains_key("name")); + assert!(!obj.contains_key("extra")); + } + + #[test] + fn extract_record_data_injects_dollar_type() { + let lua = Lua::new(); + let table = lua.create_table().unwrap(); + table.raw_set("_schema", mlua::Value::Nil).unwrap(); + table.raw_set("name", "test").unwrap(); + + let data = extract_record_data(&lua, &table, "com.example.thing").unwrap(); + assert_eq!(data["$type"], "com.example.thing"); + } +} diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs new file mode 100644 index 0000000..d23de71 --- /dev/null +++ b/tests/lua_db_api.rs @@ -0,0 +1,254 @@ +mod common; + +use happyview::AppState; +use happyview::config::Config; +use happyview::lexicon::LexiconRegistry; +use happyview::lua::db_api::register_db_api; +use mlua::Lua; +use serial_test::serial; +use std::sync::Arc; +use tokio::sync::watch; + +use common::db; + +/// Build an AppState backed by a real Postgres pool. +async fn test_state_with_pool(pool: sqlx::PgPool) -> AppState { + let config = Config { + host: "127.0.0.1".into(), + port: 3000, + database_url: String::new(), + aip_url: String::new(), + aip_public_url: String::new(), + tap_url: String::new(), + tap_admin_password: None, + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + event_log_retention_days: 30, + }; + let (tx, _) = watch::channel(vec![]); + AppState { + config, + http: reqwest::Client::new(), + db: pool, + lexicons: LexiconRegistry::new(), + collections_tx: tx, + } +} + +/// Insert seed records for testing. +async fn seed_records(pool: &sqlx::PgPool) { + let records = [ + ( + "at://did:plc:test/test.collection/rkey1", + "did:plc:test", + "test.collection", + "rkey1", + serde_json::json!({"name": "Test One", "value": 1}), + "bafyone", + ), + ( + "at://did:plc:test/test.collection/rkey2", + "did:plc:test", + "test.collection", + "rkey2", + serde_json::json!({"name": "Test Two", "value": 2}), + "bafytwo", + ), + ( + "at://did:plc:other/test.collection/rkey3", + "did:plc:other", + "test.collection", + "rkey3", + serde_json::json!({"name": "Other Record", "value": 3}), + "bafythree", + ), + ]; + + for (uri, did, collection, rkey, record, cid) in &records { + sqlx::query( + "INSERT INTO records (uri, did, collection, rkey, record, cid) VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(uri) + .bind(did) + .bind(collection) + .bind(rkey) + .bind(record) + .bind(cid) + .execute(pool) + .await + .expect("failed to seed record"); + } +} + +fn setup_lua(state: &AppState) -> Lua { + let lua = Lua::new(); + register_db_api(&lua, Arc::new(state.clone())).unwrap(); + lua +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn db_get_returns_record() { + let pool = db::test_pool().await; + db::truncate_all(&pool).await; + seed_records(&pool).await; + let state = test_state_with_pool(pool).await; + let lua = setup_lua(&state); + + let result: mlua::Table = lua + .load(r#"return db.get("at://did:plc:test/test.collection/rkey1")"#) + .eval_async() + .await + .unwrap(); + + assert_eq!( + result.get::("uri").unwrap(), + "at://did:plc:test/test.collection/rkey1" + ); + assert_eq!(result.get::("name").unwrap(), "Test One"); +} + +#[tokio::test] +#[serial] +async fn db_get_returns_nil_for_missing() { + let pool = db::test_pool().await; + db::truncate_all(&pool).await; + let state = test_state_with_pool(pool).await; + let lua = setup_lua(&state); + + let result: mlua::Value = lua + .load(r#"return db.get("at://did:plc:nonexistent/test.collection/nope")"#) + .eval_async() + .await + .unwrap(); + + assert!(result.is_nil()); +} + +#[tokio::test] +#[serial] +async fn db_query_returns_records() { + let pool = db::test_pool().await; + db::truncate_all(&pool).await; + seed_records(&pool).await; + let state = test_state_with_pool(pool).await; + let lua = setup_lua(&state); + + let result: mlua::Table = lua + .load(r#"return db.query({ collection = "test.collection" })"#) + .eval_async() + .await + .unwrap(); + + let records: mlua::Table = result.get("records").unwrap(); + assert_eq!(records.raw_len(), 3); +} + +#[tokio::test] +#[serial] +async fn db_query_respects_limit() { + let pool = db::test_pool().await; + db::truncate_all(&pool).await; + seed_records(&pool).await; + let state = test_state_with_pool(pool).await; + let lua = setup_lua(&state); + + let result: mlua::Table = lua + .load(r#"return db.query({ collection = "test.collection", limit = 1 })"#) + .eval_async() + .await + .unwrap(); + + let records: mlua::Table = result.get("records").unwrap(); + assert_eq!(records.raw_len(), 1); + + // Should have a cursor since there are more records + let cursor: String = result.get("cursor").unwrap(); + assert!(!cursor.is_empty()); +} + +#[tokio::test] +#[serial] +async fn db_count_returns_total() { + let pool = db::test_pool().await; + db::truncate_all(&pool).await; + seed_records(&pool).await; + let state = test_state_with_pool(pool).await; + let lua = setup_lua(&state); + + let count: i64 = lua + .load(r#"return db.count("test.collection")"#) + .eval_async() + .await + .unwrap(); + + assert_eq!(count, 3); +} + +#[tokio::test] +#[serial] +async fn db_count_with_did_filter() { + let pool = db::test_pool().await; + db::truncate_all(&pool).await; + seed_records(&pool).await; + let state = test_state_with_pool(pool).await; + let lua = setup_lua(&state); + + let count: i64 = lua + .load(r#"return db.count("test.collection", "did:plc:test")"#) + .eval_async() + .await + .unwrap(); + + assert_eq!(count, 2); +} + +#[tokio::test] +#[serial] +async fn db_search_finds_matching() { + let pool = db::test_pool().await; + db::truncate_all(&pool).await; + seed_records(&pool).await; + let state = test_state_with_pool(pool).await; + let lua = setup_lua(&state); + + let result: mlua::Table = lua + .load( + r#"return db.search({ collection = "test.collection", field = "name", query = "Test" })"#, + ) + .eval_async() + .await + .unwrap(); + + let records: mlua::Table = result.get("records").unwrap(); + // "Test One" and "Test Two" match; "Other Record" does not + assert_eq!(records.raw_len(), 2); +} + +#[tokio::test] +#[serial] +async fn db_raw_select_works() { + let pool = db::test_pool().await; + db::truncate_all(&pool).await; + seed_records(&pool).await; + let state = test_state_with_pool(pool).await; + let lua = setup_lua(&state); + + let result: mlua::Table = lua + .load( + r#"return db.raw("SELECT COUNT(*) as cnt FROM records WHERE collection = $1", {"test.collection"})"#, + ) + .eval_async() + .await + .unwrap(); + + // Result is an array of row tables + let first_row: mlua::Table = result.get(1).unwrap(); + let cnt: i64 = first_row.get("cnt").unwrap(); + assert_eq!(cnt, 3); +} -- 2.51.2