From 32ac80880421e3f37bb620dabcac3e88194bd5d3 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Tue, 12 Sep 2023 13:50:47 -0400 Subject: [PATCH] SDK test suites (#258) * Simple SDK test harness; SDK test module Yet to come: actual SDK tests. * Quiet clippy lint about "too many arguments" * Lints are named with underscores, not dashes... * Will this make clippy shut up? * Go nuclear on disabling `too_many_attributes` lint. Sigh. * WIP SDK test client, and fix bugs in Rust codegen Compiling the module_bindings for the sdk-test module revealed two bugs: - Enums holding structs generated incorrectly, unpacking the struct into the enum's payload. - Recursive types would cause the codegen to attempt to recursively import the current module into itself. * One (1) actual runnable test in the sdk crate * Exclude test-client from CI The sdk tests already build this crate (though they don't clippy or fmt it). Attempting to build, test, fmt or clippy it as-is will fail because the module_bindings are not committed. This is intentional, as the SDK test suite wants to generate the module_bindings during its run. * Rustfmt ignore generated module_bindings It turns out `cargo fmt` doesn't actually support the `--exclude` option the way `cargo clippy` does. Instead, just `#[rustfmt::skip]` the `mod module_bindings;` decl. * Actually commit test file... God, I'm so bad at remembering to commit new files. Anyway, add a test for deleting rows with primitive unique fields. * Make CLI tool available in tests workflow The SDK tests need to run `spacetime start`, `spacetime generate` and `spacetime publish`. * Test update events with primitive pk types; split test-client into files * Tests with `Identity` fields in tables * Tests for reducer callbacks, both successful and failing * Tests with vecs of stuff, with structs, with enums * Test that should fail, test that uses a large table with many columns * Test for resubscribe functionality * Test of reauth; fix major bug in `TestCounter` I misread `Condvar::wait_timeout_while` as `Condvar::wait_timeout_until`, and flipped my predicate. This led to false negatives (i.e. tests that passed that shouldn't have). * A fistful of doc comments * Avoid race condition running multiple tests with same client project This commit fixes a race condition which sometimes caused the SDK tests to fail because multiple `spacetime generate` processes running concurrently would clobber each others' output, potentially deleting it while a `cargo build` or `cargo run` process was running. Now, the test harness will only run `spacetime generate` at most once for any given directory. * Add env_logger to SDK test client * RUST_LOG=trace when running test clients * quieter logs in test client: only warn-level and higher --- .github/workflows/ci.yml | 8 +- Cargo.lock | 35 + Cargo.toml | 3 + crates/cli/src/subcommands/generate/mod.rs | 10 +- crates/cli/src/subcommands/generate/rust.rs | 65 +- crates/sdk/Cargo.toml | 4 + crates/sdk/src/client_cache.rs | 13 +- crates/sdk/tests/test-client/Cargo.toml | 12 + crates/sdk/tests/test-client/src/.gitignore | 1 + crates/sdk/tests/test-client/src/main.rs | 1212 +++++++++++++++++ .../tests/test-client/src/pk_test_table.rs | 311 +++++ .../test-client/src/simple_test_table.rs | 343 +++++ .../sdk/tests/test-client/src/test_counter.rs | 99 ++ .../test-client/src/unique_test_table.rs | 238 ++++ crates/sdk/tests/test.rs | 93 ++ crates/testing/Cargo.toml | 3 + crates/testing/src/lib.rs | 1 + crates/testing/src/modules.rs | 4 +- crates/testing/src/sdk.rs | 390 ++++++ modules/sdk-test/.gitignore | 17 + modules/sdk-test/Cargo.toml | 13 + modules/sdk-test/src/lib.rs | 481 +++++++ 22 files changed, 3327 insertions(+), 29 deletions(-) create mode 100644 crates/sdk/tests/test-client/Cargo.toml create mode 100644 crates/sdk/tests/test-client/src/.gitignore create mode 100644 crates/sdk/tests/test-client/src/main.rs create mode 100644 crates/sdk/tests/test-client/src/pk_test_table.rs create mode 100644 crates/sdk/tests/test-client/src/simple_test_table.rs create mode 100644 crates/sdk/tests/test-client/src/test_counter.rs create mode 100644 crates/sdk/tests/test-client/src/unique_test_table.rs create mode 100644 crates/sdk/tests/test.rs create mode 100644 crates/testing/src/sdk.rs create mode 100644 modules/sdk-test/.gitignore create mode 100644 modules/sdk-test/Cargo.toml create mode 100644 modules/sdk-test/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd392f9257..ee38766950 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,13 +30,17 @@ jobs: - uses: dsherret/rust-toolchain-file@v1 + - name: Install CLI tool + run: | + cargo install --path crates/cli + - name: Create /stdb dir run: | sudo mkdir /stdb sudo chmod 777 /stdb - name: Run cargo test - run: cargo test --all --features odb_rocksdb,odb_sled + run: cargo test --all --exclude test-client --features odb_rocksdb,odb_sled lints: name: Lints @@ -52,7 +56,7 @@ jobs: run: cargo fmt --all -- --check - name: Run cargo clippy - run: cargo clippy --all --tests --features odb_rocksdb,odb_sled -- -D warnings + run: cargo clippy --all --exclude test-client --tests --features odb_rocksdb,odb_sled -- -D warnings wasm_bindings: name: Build and test wasm bindings diff --git a/Cargo.lock b/Cargo.lock index ba22681c3d..06aff0c00b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1369,6 +1369,19 @@ dependencies = [ "syn 2.0.22", ] +[[package]] +name = "env_logger" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.0" @@ -3742,6 +3755,14 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "sdk-test-module" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + [[package]] name = "seahash" version = "4.1.0" @@ -4360,6 +4381,7 @@ dependencies = [ "spacetimedb-client-api-messages", "spacetimedb-lib", "spacetimedb-sats", + "spacetimedb-testing", "tokio", "tokio-tungstenite 0.19.0", ] @@ -4392,6 +4414,9 @@ name = "spacetimedb-testing" version = "0.1.0" dependencies = [ "anyhow", + "duct", + "lazy_static", + "rand 0.8.5", "serde_json", "serial_test", "spacetimedb-client-api", @@ -4717,6 +4742,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "test-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "env_logger", + "spacetimedb-sdk", + "tokio", +] + [[package]] name = "textwrap" version = "0.16.0" diff --git a/Cargo.toml b/Cargo.toml index 0817e493b5..0b5923fd7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ members = [ "modules/benchmarks", "modules/spacetimedb-quickstart", "modules/quickstart-chat", + "modules/sdk-test", + "crates/sdk/tests/test-client", ] default-members = ["crates/cli"] # cargo feature graph resolver. v2 is default in edition2021 but workspace @@ -79,6 +81,7 @@ dirs = "5.0.1" duct = "0.13.5" email_address = "0.2.4" enum-as-inner = "0.6" +env_logger = "0.10" flate2 = "1.0.24" fs2 = "0.4.3" fs-err = "2.9.0" diff --git a/crates/cli/src/subcommands/generate/mod.rs b/crates/cli/src/subcommands/generate/mod.rs index 2751139125..3ce91222cc 100644 --- a/crates/cli/src/subcommands/generate/mod.rs +++ b/crates/cli/src/subcommands/generate/mod.rs @@ -226,24 +226,20 @@ impl GenItem { match self { GenItem::Table(table) => { let code = rust::autogen_rust_table(ctx, table); - let name = table.name.to_case(Case::Snake); - Some((name + ".rs", code)) + Some((rust::rust_type_file_name(&table.name), code)) } GenItem::TypeAlias(TypeAlias { name, ty }) => { - let filename = name.replace('.', "").to_case(Case::Snake); - let filename = filename + ".rs"; let code = match &ctx.typespace[*ty] { AlgebraicType::Sum(sum) => rust::autogen_rust_sum(ctx, name, sum), AlgebraicType::Product(prod) => rust::autogen_rust_tuple(ctx, name, prod), _ => todo!(), }; - Some((filename, code)) + Some((rust::rust_type_file_name(name), code)) } GenItem::Reducer(reducer) if reducer.name == "__init__" => None, GenItem::Reducer(reducer) => { let code = rust::autogen_rust_reducer(ctx, reducer); - let name = reducer.name.to_case(Case::Snake); - Some((name + "_reducer.rs", code)) + Some((rust::rust_reducer_file_name(&reducer.name), code)) } } } diff --git a/crates/cli/src/subcommands/generate/rust.rs b/crates/cli/src/subcommands/generate/rust.rs index bd5bcff3e7..2cb404687e 100644 --- a/crates/cli/src/subcommands/generate/rust.rs +++ b/crates/cli/src/subcommands/generate/rust.rs @@ -199,10 +199,15 @@ pub fn autogen_rust_sum(ctx: &GenCtx, name: &str, sum_type: &SumType) -> String print_file_header(out); + // Pass this file into `gen_and_print_imports` to avoid recursively importing self + // for recursive types. + let file_name = name.to_case(Case::Snake); + let this_file = (file_name.as_str(), name); + // For some reason, deref coercion doesn't work on `&sum_type.variants` here - rustc // wants to pass it as `&Vec<_>`, not `&[_]`. The slicing index `[..]` forces passing // as a slice. - gen_and_print_imports(ctx, out, &sum_type.variants[..], generate_imports_variants); + gen_and_print_imports(ctx, out, &sum_type.variants[..], generate_imports_variants, this_file); out.newline(); @@ -239,16 +244,6 @@ fn write_enum_variant(ctx: &GenCtx, out: &mut Indenter, variant: &SumTypeVariant // ``` writeln!(out, ",").unwrap(); } - AlgebraicType::Product(ProductType { elements }) => { - // If the contained type is a non-empty product, i.e. this variant is - // struct-like, write it with braces and named fields. - write_struct_type_fields_in_braces( - ctx, out, elements, - // Do not `pub`-qualify fields because enum fields are always public, and - // rustc errors on the redundant `pub`. - false, - ); - } otherwise => { // If the contained type is not a product, i.e. this variant has a single // member, write it tuple-style, with parens. @@ -352,6 +347,16 @@ pub fn autogen_rust_table(ctx: &GenCtx, table: &TableDef) -> String { // - Complicated because `HashMap` is not `Hash`. // - others? +pub fn rust_type_file_name(type_name: &str) -> String { + let filename = type_name.replace('.', "").to_case(Case::Snake); + filename + ".rs" +} + +pub fn rust_reducer_file_name(type_name: &str) -> String { + let filename = type_name.replace('.', "").to_case(Case::Snake); + filename + "_reducer.rs" +} + const STRUCT_DERIVES: &[&str] = &["#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]"]; fn print_struct_derives(output: &mut Indenter) { @@ -363,7 +368,15 @@ fn begin_rust_struct_def_shared(ctx: &GenCtx, out: &mut Indenter, name: &str, el print_spacetimedb_imports(out); - gen_and_print_imports(ctx, out, elements, generate_imports_elements); + // Pass this file into `gen_and_print_imports` to avoid recursively importing self + // for recursive types. + // + // The file_name will be incorrect for reducer arg structs, but that doesn't matter + // because it's impossible for a reducer arg struct to be recursive. + let file_name = name.to_case(Case::Snake); + let this_file = (file_name.as_str(), name); + + gen_and_print_imports(ctx, out, elements, generate_imports_elements, this_file); out.newline(); @@ -1099,17 +1112,35 @@ fn generate_imports(ctx: &GenCtx, imports: &mut Imports, ty: &AlgebraicType) { } } -fn print_imports(out: &mut Indenter, imports: Imports) { +/// Print `use super::` imports for each of the `imports`, except `this_file`. +/// +/// `this_file` is passed and excluded for the case of recursive types: +/// without it, the definition for a type like `struct Foo { foos: Vec }` +/// would attempt to include `import super::foo::Foo`, which fails to compile. +fn print_imports(out: &mut Indenter, imports: Imports, this_file: (&str, &str)) { for (module_name, type_name) in imports { - writeln!(out, "use super::{}::{};", module_name, type_name).unwrap(); + if (module_name.as_str(), type_name.as_str()) != this_file { + writeln!(out, "use super::{}::{};", module_name, type_name).unwrap(); + } } } -fn gen_and_print_imports(ctx: &GenCtx, out: &mut Indenter, roots: Roots, search_fn: SearchFn) -where +/// Use `search_function` on `roots` to detect required imports, then print them with `print_imports`. +/// +/// `this_file` is passed and excluded for the case of recursive types: +/// without it, the definition for a type like `struct Foo { foos: Vec }` +/// would attempt to include `import super::foo::Foo`, which fails to compile. +fn gen_and_print_imports( + ctx: &GenCtx, + out: &mut Indenter, + roots: Roots, + search_fn: SearchFn, + this_file: (&str, &str), +) where SearchFn: FnOnce(&GenCtx, &mut Imports, Roots), { let mut imports = HashSet::new(); search_fn(ctx, &mut imports, roots); - print_imports(out, imports); + + print_imports(out, imports, this_file); } diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 8abdc493e5..86e4d6a092 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -27,7 +27,11 @@ tokio.workspace = true tokio-tungstenite.workspace = true [dev-dependencies] +# for quickstart-chat and cursive-chat examples hex.workspace = true # for cursive-chat example cursive.workspace = true futures-channel.workspace = true + +# for tests +spacetimedb-testing = { path = "../testing" } diff --git a/crates/sdk/src/client_cache.rs b/crates/sdk/src/client_cache.rs index bbc94bfe82..02d85fb69b 100644 --- a/crates/sdk/src/client_cache.rs +++ b/crates/sdk/src/client_cache.rs @@ -203,6 +203,12 @@ impl TableCache { ); for (row_hash, row) in prev_subs.into_iter() { + log::trace!( + "Initalizing table {:?}: row previously resident: {:?} hash: {:?}", + T::TABLE_NAME, + row, + row_hash, + ); if diff.insert(row_hash, DiffEntry::Delete(row)).is_some() { // This should be impossible, but just in case... log::error!("Found duplicate row in existing `TableCache` for {:?}", T::TABLE_NAME); @@ -238,7 +244,12 @@ impl TableCache { ); } Ok(row) => { - log::trace!("Initializing table {:?}: got new row {:?}", T::TABLE_NAME, row); + log::trace!( + "Initializing table {:?}: got new row {:?}. Hash: {:?}", + T::TABLE_NAME, + row, + row_pk + ); diff.insert(row_pk, DiffEntry::Insert(row)); } }, diff --git a/crates/sdk/tests/test-client/Cargo.toml b/crates/sdk/tests/test-client/Cargo.toml new file mode 100644 index 0000000000..bb82901c1c --- /dev/null +++ b/crates/sdk/tests/test-client/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "test-client" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +spacetimedb-sdk = { path = "../.." } +tokio.workspace = true +anyhow.workspace = true +env_logger.workspace = true diff --git a/crates/sdk/tests/test-client/src/.gitignore b/crates/sdk/tests/test-client/src/.gitignore new file mode 100644 index 0000000000..85e3b6afeb --- /dev/null +++ b/crates/sdk/tests/test-client/src/.gitignore @@ -0,0 +1 @@ +module_bindings diff --git a/crates/sdk/tests/test-client/src/main.rs b/crates/sdk/tests/test-client/src/main.rs new file mode 100644 index 0000000000..0817d1bb2a --- /dev/null +++ b/crates/sdk/tests/test-client/src/main.rs @@ -0,0 +1,1212 @@ +use spacetimedb_sdk::{ + identity::{identity, load_credentials, once_on_connect, save_credentials}, + once_on_subscription_applied, + reducer::Status, + subscribe, + table::TableType, +}; + +#[allow(clippy::too_many_arguments)] +#[allow(clippy::large_enum_variant)] +#[rustfmt::skip] +mod module_bindings; + +use module_bindings::*; + +mod test_counter; +use test_counter::TestCounter; + +mod simple_test_table; +use simple_test_table::insert_one; + +mod pk_test_table; +use pk_test_table::insert_update_delete_one; + +mod unique_test_table; +use unique_test_table::insert_then_delete_one; + +const LOCALHOST: &str = "http://localhost:3000"; + +fn db_name_or_panic() -> String { + std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") +} + +/// Register a panic hook which will exit the process whenever any thread panics. +/// +/// This allows us to fail tests by panicking in callbacks. +fn exit_on_panic() { + // The default panic hook is responsible for printing the panic message and backtrace to stderr. + // Grab a handle on it, and invoke it in our custom hook before exiting. + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + // Print panic information + default_hook(panic_info); + + // Close the websocket gracefully before exiting. + spacetimedb_sdk::disconnect(); + + // Exit the process with a non-zero code to denote failure. + std::process::exit(1); + })); +} + +fn main() { + env_logger::init(); + exit_on_panic(); + + let test = std::env::args() + .nth(1) + .expect("Pass a test name as a command-line argument to the test client"); + + match &*test { + "insert_primitive" => exec_insert_primitive(), + "delete_primitive" => exec_delete_primitive(), + "update_primitive" => exec_update_primitive(), + + "insert_identity" => exec_insert_identity(), + "delete_identity" => exec_delete_identity(), + "update_identity" => exec_update_identity(), + + "on_reducer" => exec_on_reducer(), + "fail_reducer" => exec_fail_reducer(), + + "insert_vec" => exec_insert_vec(), + + "insert_struct" => exec_insert_struct(), + "insert_simple_enum" => exec_insert_simple_enum(), + "insert_enum_with_payload" => exec_insert_enum_with_payload(), + + "insert_long_table" => exec_insert_long_table(), + + "resubscribe" => exec_resubscribe(), + + "reconnect" => exec_reconnect(), + + "reauth_part_1" => exec_reauth_part_1(), + "reauth_part_2" => exec_reauth_part_2(), + + "should_fail" => exec_should_fail(), + + _ => panic!("Unknown test: {}", test), + } +} + +fn assert_table_empty() -> anyhow::Result<()> { + let count = T::count(); + if count != 0 { + anyhow::bail!( + "Expected table {} to be empty, but found {} rows resident", + T::TABLE_NAME, + count, + ) + } + Ok(()) +} + +/// Each test runs against a fresh DB, so all tables should be empty until we call an insert reducer. +/// +/// We'll call this function within our initial `on_subscription_applied` callback to verify that. +fn assert_all_tables_empty() -> anyhow::Result<()> { + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + + assert_table_empty::()?; + assert_table_empty::()?; + + assert_table_empty::()?; + + assert_table_empty::()?; + + Ok(()) +} + +/// A great big honking query that subscribes to all rows from all tables. +const SUBSCRIBE_ALL: &[&str] = &[ + "SELECT * FROM OneU8;", + "SELECT * FROM OneU16;", + "SELECT * FROM OneU32;", + "SELECT * FROM OneU64;", + "SELECT * FROM OneU128;", + "SELECT * FROM OneI8;", + "SELECT * FROM OneI16;", + "SELECT * FROM OneI32;", + "SELECT * FROM OneI64;", + "SELECT * FROM OneI128;", + "SELECT * FROM OneBool;", + "SELECT * FROM OneF32;", + "SELECT * FROM OneF64;", + "SELECT * FROM OneString;", + "SELECT * FROM OneIdentity;", + "SELECT * FROM OneSimpleEnum;", + "SELECT * FROM OneEnumWithPayload;", + "SELECT * FROM OneUnitStruct;", + "SELECT * FROM OneByteStruct;", + "SELECT * FROM OneEveryPrimitiveStruct;", + "SELECT * FROM OneEveryVecStruct;", + "SELECT * FROM VecU8;", + "SELECT * FROM VecU16;", + "SELECT * FROM VecU32;", + "SELECT * FROM VecU64;", + "SELECT * FROM VecU128;", + "SELECT * FROM VecI8;", + "SELECT * FROM VecI16;", + "SELECT * FROM VecI32;", + "SELECT * FROM VecI64;", + "SELECT * FROM VecI128;", + "SELECT * FROM VecBool;", + "SELECT * FROM VecF32;", + "SELECT * FROM VecF64;", + "SELECT * FROM VecString;", + "SELECT * FROM VecIdentity;", + "SELECT * FROM VecSimpleEnum;", + "SELECT * FROM VecEnumWithPayload;", + "SELECT * FROM VecUnitStruct;", + "SELECT * FROM VecByteStruct;", + "SELECT * FROM VecEveryPrimitiveStruct;", + "SELECT * FROM VecEveryVecStruct;", + "SELECT * FROM UniqueU8;", + "SELECT * FROM UniqueU16;", + "SELECT * FROM UniqueU32;", + "SELECT * FROM UniqueU64;", + "SELECT * FROM UniqueU128;", + "SELECT * FROM UniqueI8;", + "SELECT * FROM UniqueI16;", + "SELECT * FROM UniqueI32;", + "SELECT * FROM UniqueI64;", + "SELECT * FROM UniqueI128;", + "SELECT * FROM UniqueBool;", + "SELECT * FROM UniqueString;", + "SELECT * FROM UniqueIdentity;", + "SELECT * FROM PkU8;", + "SELECT * FROM PkU16;", + "SELECT * FROM PkU32;", + "SELECT * FROM PkU64;", + "SELECT * FROM PkU128;", + "SELECT * FROM PkI8;", + "SELECT * FROM PkI16;", + "SELECT * FROM PkI32;", + "SELECT * FROM PkI64;", + "SELECT * FROM PkI128;", + "SELECT * FROM PkBool;", + "SELECT * FROM PkString;", + "SELECT * FROM PkIdentity;", + "SELECT * FROM LargeTable;", + "SELECT * FROM TableHoldsTable;", +]; + +/// This tests that we can: +/// - Pass primitive types to reducers. +/// - Deserialize primitive types in rows and in reducer arguments. +/// - Observe `on_insert` callbacks with appropriate reducer events. +fn exec_insert_primitive() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + { + let test_counter = test_counter.clone(); + once_on_subscription_applied(move || { + insert_one::(&test_counter, 0); + insert_one::(&test_counter, 0); + insert_one::(&test_counter, 0); + insert_one::(&test_counter, 0); + insert_one::(&test_counter, 0); + + insert_one::(&test_counter, 0); + insert_one::(&test_counter, 0); + insert_one::(&test_counter, 0); + insert_one::(&test_counter, 0); + insert_one::(&test_counter, 0); + + insert_one::(&test_counter, false); + + insert_one::(&test_counter, 0.0); + insert_one::(&test_counter, 0.0); + + insert_one::(&test_counter, "".to_string()); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + } + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); +} + +/// This tests that we can observe `on_delete` callbacks. +fn exec_delete_primitive() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + { + let test_counter = test_counter.clone(); + once_on_subscription_applied(move || { + insert_then_delete_one::(&test_counter, 0, 0xbeef); + insert_then_delete_one::(&test_counter, 0, 0xbeef); + insert_then_delete_one::(&test_counter, 0, 0xbeef); + insert_then_delete_one::(&test_counter, 0, 0xbeef); + insert_then_delete_one::(&test_counter, 0, 0xbeef); + + insert_then_delete_one::(&test_counter, 0, 0xbeef); + insert_then_delete_one::(&test_counter, 0, 0xbeef); + insert_then_delete_one::(&test_counter, 0, 0xbeef); + insert_then_delete_one::(&test_counter, 0, 0xbeef); + insert_then_delete_one::(&test_counter, 0, 0xbeef); + + insert_then_delete_one::(&test_counter, false, 0xbeef); + + insert_then_delete_one::(&test_counter, "".to_string(), 0xbeef); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + } + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); + + assert_all_tables_empty().unwrap(); +} + +/// This tests that we can distinguish between `on_update` and `on_delete` callbacks for tables with primary keys. +fn exec_update_primitive() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + { + let test_counter = test_counter.clone(); + once_on_subscription_applied(move || { + insert_update_delete_one::(&test_counter, 0, 0xbeef, 0xbabe); + insert_update_delete_one::(&test_counter, 0, 0xbeef, 0xbabe); + insert_update_delete_one::(&test_counter, 0, 0xbeef, 0xbabe); + insert_update_delete_one::(&test_counter, 0, 0xbeef, 0xbabe); + insert_update_delete_one::(&test_counter, 0, 0xbeef, 0xbabe); + + insert_update_delete_one::(&test_counter, 0, 0xbeef, 0xbabe); + insert_update_delete_one::(&test_counter, 0, 0xbeef, 0xbabe); + insert_update_delete_one::(&test_counter, 0, 0xbeef, 0xbabe); + insert_update_delete_one::(&test_counter, 0, 0xbeef, 0xbabe); + insert_update_delete_one::(&test_counter, 0, 0xbeef, 0xbabe); + + insert_update_delete_one::(&test_counter, false, 0xbeef, 0xbabe); + + insert_update_delete_one::(&test_counter, "".to_string(), 0xbeef, 0xbabe); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + } + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); + + assert_all_tables_empty().unwrap(); +} + +/// This tests that we can serialize and deserialize `Identity` in various contexts. +fn exec_insert_identity() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + { + let test_counter = test_counter.clone(); + once_on_subscription_applied(move || { + insert_one::(&test_counter, identity().unwrap()); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + } + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); +} + +/// This test doesn't add much alongside `exec_insert_identity` and `exec_delete_primitive`, +/// but it's here for symmetry. +fn exec_delete_identity() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + { + let test_counter = test_counter.clone(); + once_on_subscription_applied(move || { + insert_then_delete_one::(&test_counter, identity().unwrap(), 0xbeef); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + } + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); + + assert_all_tables_empty().unwrap(); +} + +/// This tests that we can distinguish between `on_delete` and `on_update` events +/// for tables with `Identity` primary keys. +fn exec_update_identity() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + { + let test_counter = test_counter.clone(); + once_on_subscription_applied(move || { + insert_update_delete_one::(&test_counter, identity().unwrap(), 0xbeef, 0xbabe); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + } + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); + + assert_all_tables_empty().unwrap(); +} + +/// This tests that we can observe reducer callbacks for successful reducer runs. +fn exec_on_reducer() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + let reducer_result = test_counter.add_test("reducer-callback"); + + let value = 128; + + once_on_insert_one_u_8(move |caller, status, arg| { + let run_checks = || { + if *arg != value { + anyhow::bail!("Unexpected reducer argument. Expected {} but found {}", value, *arg); + } + if *caller != identity().unwrap() { + anyhow::bail!( + "Unexpected caller. Expected:\n{:?}\nFound:\n{:?}", + identity().unwrap(), + caller + ); + } + if !matches!(status, Status::Committed) { + anyhow::bail!("Unexpected status. Expected Committed but found {:?}", status); + } + if OneU8::count() != 1 { + anyhow::bail!("Expected 1 row in table OneU8, but found {}", OneU8::count()); + } + let row = OneU8::iter().next().unwrap(); + if row.n != value { + anyhow::bail!("Unexpected row value. Expected {} but found {:?}", value, row); + } + Ok(()) + }; + + reducer_result(run_checks()); + }); + + once_on_subscription_applied(move || { + insert_one_u_8(value); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); +} + +/// This tests that we can observe reducer callbacks for failed reducers. +fn exec_fail_reducer() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + let reducer_success_result = test_counter.add_test("reducer-callback-success"); + let reducer_fail_result = test_counter.add_test("reducer-callback-fail"); + + let key = 128; + let initial_data = 0xbeef; + let fail_data = 0xbabe; + + once_on_insert_pk_u_8(move |caller, status, arg_key, arg_val| { + let run_checks = || { + if *arg_key != key { + anyhow::bail!("Unexpected reducer argument. Expected {} but found {}", key, *arg_key); + } + if *arg_val != initial_data { + anyhow::bail!( + "Unexpected reducer argument. Expected {} but found {}", + initial_data, + *arg_val + ); + } + if *caller != identity().unwrap() { + anyhow::bail!( + "Unexpected caller. Expected:\n{:?}\nFound:\n{:?}", + identity().unwrap(), + caller + ); + } + if !matches!(status, Status::Committed) { + anyhow::bail!("Unexpected status. Expected Committed but found {:?}", status); + } + if PkU8::count() != 1 { + anyhow::bail!("Expected 1 row in table PkU8, but found {}", PkU8::count()); + } + let row = PkU8::iter().next().unwrap(); + if row.n != key || row.data != initial_data { + anyhow::bail!( + "Unexpected row value. Expected ({}, {}) but found {:?}", + key, + initial_data, + row + ); + } + Ok(()) + }; + + reducer_success_result(run_checks()); + + once_on_insert_pk_u_8(move |caller, status, arg_key, arg_val| { + let run_checks = || { + if *arg_key != key { + anyhow::bail!("Unexpected reducer argument. Expected {} but found {}", key, *arg_key); + } + if *arg_val != fail_data { + anyhow::bail!( + "Unexpected reducer argument. Expected {} but found {}", + initial_data, + *arg_val + ); + } + if *caller != identity().unwrap() { + anyhow::bail!( + "Unexpected caller. Expected:\n{:?}\nFound:\n{:?}", + identity().unwrap(), + caller + ); + } + if !matches!(status, Status::Failed(_)) { + anyhow::bail!("Unexpected status. Expected Failed but found {:?}", status); + } + if PkU8::count() != 1 { + anyhow::bail!("Expected 1 row in table PkU8, but found {}", PkU8::count()); + } + let row = PkU8::iter().next().unwrap(); + if row.n != key || row.data != initial_data { + anyhow::bail!( + "Unexpected row value. Expected ({}, {}) but found {:?}", + key, + initial_data, + row + ); + } + Ok(()) + }; + + reducer_fail_result(run_checks()); + }); + + insert_pk_u_8(key, fail_data); + }); + + once_on_subscription_applied(move || { + insert_pk_u_8(key, initial_data); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); +} + +/// This tests that we can serialize and deserialize `Vec` in various contexts. +fn exec_insert_vec() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + { + let test_counter = test_counter.clone(); + once_on_subscription_applied(move || { + insert_one::(&test_counter, vec![0, 1]); + insert_one::(&test_counter, vec![0, 1]); + insert_one::(&test_counter, vec![0, 1]); + insert_one::(&test_counter, vec![0, 1]); + insert_one::(&test_counter, vec![0, 1]); + + insert_one::(&test_counter, vec![0, 1]); + insert_one::(&test_counter, vec![0, 1]); + insert_one::(&test_counter, vec![0, 1]); + insert_one::(&test_counter, vec![0, 1]); + insert_one::(&test_counter, vec![0, 1]); + + insert_one::(&test_counter, vec![false, true]); + + insert_one::(&test_counter, vec![0.0, 1.0]); + insert_one::(&test_counter, vec![0.0, 1.0]); + + insert_one::(&test_counter, vec!["zero".to_string(), "one".to_string()]); + + insert_one::(&test_counter, vec![identity().unwrap()]); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + } + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); +} + +/// This tests that we can serialize and deserialize structs in various contexts. +fn exec_insert_struct() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + { + let test_counter = test_counter.clone(); + once_on_subscription_applied(move || { + insert_one::(&test_counter, UnitStruct {}); + insert_one::(&test_counter, ByteStruct { b: 0 }); + insert_one::( + &test_counter, + EveryPrimitiveStruct { + a: 0, + b: 1, + c: 2, + d: 3, + e: 4, + f: -1, + g: -2, + h: -3, + i: -4, + j: -5, + k: false, + l: 1.0, + m: -1.0, + n: "string".to_string(), + o: identity().unwrap(), + }, + ); + insert_one::( + &test_counter, + EveryVecStruct { + a: vec![], + b: vec![1], + c: vec![2, 2], + d: vec![3, 3, 3], + e: vec![4, 4, 4, 4], + f: vec![-1], + g: vec![-2, -2], + h: vec![-3, -3, -3], + i: vec![-4, -4, -4, -4], + j: vec![-5, -5, -5, -5, -5], + k: vec![false, true, true, false], + l: vec![0.0, -1.0, 1.0, -2.0, 2.0], + m: vec![0.0, -0.5, 0.5, -1.5, 1.5], + n: ["vec", "of", "strings"].into_iter().map(str::to_string).collect(), + o: vec![identity().unwrap()], + }, + ); + + insert_one::(&test_counter, vec![UnitStruct {}]); + insert_one::(&test_counter, vec![ByteStruct { b: 0 }]); + insert_one::( + &test_counter, + vec![EveryPrimitiveStruct { + a: 0, + b: 1, + c: 2, + d: 3, + e: 4, + f: -1, + g: -2, + h: -3, + i: -4, + j: -5, + k: false, + l: 1.0, + m: -1.0, + n: "string".to_string(), + o: identity().unwrap(), + }], + ); + insert_one::( + &test_counter, + vec![EveryVecStruct { + a: vec![], + b: vec![1], + c: vec![2, 2], + d: vec![3, 3, 3], + e: vec![4, 4, 4, 4], + f: vec![-1], + g: vec![-2, -2], + h: vec![-3, -3, -3], + i: vec![-4, -4, -4, -4], + j: vec![-5, -5, -5, -5, -5], + k: vec![false, true, true, false], + l: vec![0.0, -1.0, 1.0, -2.0, 2.0], + m: vec![0.0, -0.5, 0.5, -1.5, 1.5], + n: ["vec", "of", "strings"].into_iter().map(str::to_string).collect(), + o: vec![identity().unwrap()], + }], + ); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + } + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); +} + +/// This tests that we can serialize and deserialize C-style enums in various contexts. +fn exec_insert_simple_enum() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + { + let test_counter = test_counter.clone(); + once_on_subscription_applied(move || { + insert_one::(&test_counter, SimpleEnum::One); + insert_one::(&test_counter, vec![SimpleEnum::Zero, SimpleEnum::One, SimpleEnum::Two]); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + } + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); +} + +/// This tests that we can serialize and deserialize sum types in various contexts. +fn exec_insert_enum_with_payload() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + { + let test_counter = test_counter.clone(); + once_on_subscription_applied(move || { + insert_one::(&test_counter, EnumWithPayload::U8(0)); + insert_one::( + &test_counter, + vec![ + EnumWithPayload::U8(0), + EnumWithPayload::U16(1), + EnumWithPayload::U32(2), + EnumWithPayload::U64(3), + EnumWithPayload::U128(4), + EnumWithPayload::I8(0), + EnumWithPayload::I16(-1), + EnumWithPayload::I32(-2), + EnumWithPayload::I64(-3), + EnumWithPayload::I128(-4), + EnumWithPayload::Bool(true), + EnumWithPayload::F32(0.0), + EnumWithPayload::F64(100.0), + EnumWithPayload::Str("enum holds string".to_string()), + EnumWithPayload::Identity(identity().unwrap()), + EnumWithPayload::Bytes(vec![0xde, 0xad, 0xbe, 0xef]), + EnumWithPayload::Strings( + ["enum", "of", "vec", "of", "strings"] + .into_iter() + .map(str::to_string) + .collect(), + ), + EnumWithPayload::SimpleEnums(vec![SimpleEnum::Zero, SimpleEnum::One, SimpleEnum::Two]), + ], + ); + + sub_applied_nothing_result(assert_all_tables_empty()); + }); + } + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); +} + +/// This tests that the test machinery itself is functional and can detect failures. +fn exec_should_fail() { + let test_counter = TestCounter::new(); + let fail = test_counter.add_test("should-fail"); + fail(Err(anyhow::anyhow!("This is an intentional failure"))); + test_counter.wait_for_all(); +} + +macro_rules! assert_eq_or_bail { + ($expected:expr, $found:expr) => {{ + let expected = &$expected; + let found = &$found; + if expected != found { + anyhow::bail!( + "Expected {} => {:?} but found {} => {:?}", + stringify!($expected), + expected, + stringify!($found), + found + ); + } + }}; +} + +/// This test invokes a reducer with many arguments of many types, +/// and observes a callback for an inserted table with many columns of many types. +fn exec_insert_long_table() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let conn_result = test_counter.add_test("connect"); + + let sub_result = test_counter.add_test("subscribe"); + + let sub_applied_nothing_result = test_counter.add_test("on_subscription_applied_nothing"); + + { + let test_counter = test_counter.clone(); + let mut large_table_result = Some(test_counter.add_test("insert-large-table")); + once_on_subscription_applied(move || { + let every_primitive_struct = EveryPrimitiveStruct { + a: 0, + b: 1, + c: 2, + d: 3, + e: 4, + f: 0, + g: -1, + h: -2, + i: -3, + j: -4, + k: false, + l: 0.0, + m: 1.0, + n: "string".to_string(), + o: identity().unwrap(), + }; + let every_vec_struct = EveryVecStruct { + a: vec![0], + b: vec![1], + c: vec![2], + d: vec![3], + e: vec![4], + f: vec![0], + g: vec![-1], + h: vec![-2], + i: vec![-3], + j: vec![-4], + k: vec![false], + l: vec![0.0], + m: vec![1.0], + n: vec!["string".to_string()], + o: vec![identity().unwrap()], + }; + + let every_primitive_dup = every_primitive_struct.clone(); + let every_vec_dup = every_vec_struct.clone(); + LargeTable::on_insert(move |row, reducer_event| { + if large_table_result.is_some() { + let run_tests = || { + assert_eq_or_bail!(row.a, 0); + assert_eq_or_bail!(row.b, 1); + assert_eq_or_bail!(row.c, 2); + assert_eq_or_bail!(row.d, 3); + assert_eq_or_bail!(row.e, 4); + assert_eq_or_bail!(row.f, 0); + assert_eq_or_bail!(row.g, -1); + assert_eq_or_bail!(row.h, -2); + assert_eq_or_bail!(row.i, -3); + assert_eq_or_bail!(row.j, -4); + assert_eq_or_bail!(row.k, false); + assert_eq_or_bail!(row.l, 0.0); + assert_eq_or_bail!(row.m, 1.0); + assert_eq_or_bail!(&row.n, "string"); + assert_eq_or_bail!(row.o, SimpleEnum::Zero); + assert_eq_or_bail!(row.p, EnumWithPayload::Bool(false)); + assert_eq_or_bail!(row.q, UnitStruct {}); + assert_eq_or_bail!(row.r, ByteStruct { b: 0b10101010 }); + assert_eq_or_bail!(row.s, every_primitive_struct); + assert_eq_or_bail!(row.t, every_vec_struct); + if !matches!(reducer_event, Some(ReducerEvent::InsertLargeTable(_))) { + anyhow::bail!( + "Unexpected reducer event: expeced InsertLargeTable but found {:?}", + reducer_event + ); + } + Ok(()) + }; + (large_table_result.take().unwrap())(run_tests()); + } + }); + insert_large_table( + 0, + 1, + 2, + 3, + 4, + 0, + -1, + -2, + -3, + -4, + false, + 0.0, + 1.0, + "string".to_string(), + SimpleEnum::Zero, + EnumWithPayload::Bool(false), + UnitStruct {}, + ByteStruct { b: 0b10101010 }, + every_primitive_dup, + every_vec_dup, + ); + + sub_applied_nothing_result(assert_all_tables_empty()) + }); + } + + once_on_connect(move |_| sub_result(subscribe(SUBSCRIBE_ALL))); + + conn_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); +} + +/// This tests the behavior of re-subscribing +/// by observing `on_delete` callbacks of newly-unsubscribed rows +/// and `on_insert` callbacks of newly-subscribed rows. +fn exec_resubscribe() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + // Boring stuff first: connect and subscribe to everything. + let connect_result = test_counter.add_test("connect"); + let subscribe_result = test_counter.add_test("initial-subscribe"); + let sub_applied_result = test_counter.add_test("initial-subscription-nothing"); + + once_on_subscription_applied(move || { + sub_applied_result(assert_all_tables_empty()); + }); + + once_on_connect(|_| { + subscribe_result(subscribe(SUBSCRIBE_ALL)); + }); + + connect_result(connect(LOCALHOST, &name, None)); + + // Wait for all previous checks before continuing. + test_counter.wait_for_all(); + + // Insert 256 rows of `OneU8`. + // At this point, we should be subscribed to all of them. + let test_counter = TestCounter::new(); + let mut insert_u8s = (0..=255) + .map(|n| Some(test_counter.add_test(format!("insert-{}", n)))) + .collect::>(); + let on_insert_u8 = OneU8::on_insert(move |row, _| { + let n = row.n; + (insert_u8s[n as usize].take().unwrap())(Ok(())); + }); + for n in 0..=255 { + insert_one_u_8(n as u8); + } + // Wait for all previous checks before continuing, + test_counter.wait_for_all(); + // and remove the callback now that we're done with it. + OneU8::remove_on_insert(on_insert_u8); + + // Re-subscribe with a query that excludes the lower half of the `OneU8` rows, + // and observe `on_delete` callbacks for those rows. + let test_counter = TestCounter::new(); + let mut delete_u8s = (0..128) + .map(|n| Some(test_counter.add_test(format!("unsubscribe-{}-delete", n)))) + .collect::>(); + let on_delete_verify = OneU8::on_delete(move |row, _| { + let n = row.n; + // This indexing will panic if n > 127. + (delete_u8s[n as usize].take().unwrap())(Ok(())); + }); + // There should be no newly-subscribed rows, so we'll panic if we get an on-insert event. + let on_insert_panic = OneU8::on_insert(|row, _| { + panic!("Unexpected insert during re-subscribe for {:?}", row); + }); + let subscribe_less_result = test_counter.add_test("resubscribe-fewer-matches"); + once_on_subscription_applied(move || { + let run_checks = || { + assert_eq_or_bail!(128, OneU8::count()); + if let Some(row) = OneU8::iter().find(|row| row.n < 128) { + anyhow::bail!("After subscribing to OneU8 WHERE n > 127, found row with n < {}", row.n); + } + Ok(()) + }; + subscribe_less_result(run_checks()); + }); + let subscribe_result = test_counter.add_test("resubscribe"); + subscribe_result(subscribe(&["SELECT * FROM OneU8 WHERE n > 127"])); + // Wait before continuing, and remove callbacks. + test_counter.wait_for_all(); + OneU8::remove_on_delete(on_delete_verify); + OneU8::remove_on_insert(on_insert_panic); + + // Re-subscribe with a query that includes all of the `OneU8` rows again, + // and observe `on_insert` callbacks for the lower half. + let test_counter = TestCounter::new(); + let mut insert_u8s = (0..128) + .map(|n| Some(test_counter.add_test(format!("resubscribe-{}-insert", n)))) + .collect::>(); + OneU8::on_insert(move |row, _| { + let n = row.n; + // This indexing will panic if n > 127. + (insert_u8s[n as usize].take().unwrap())(Ok(())); + }); + // There should be no newly-unsubscribed rows, so we'll panic if we get an on-delete event. + OneU8::on_delete(|row, _| { + panic!("Unexpected delete during re-subscribe for {:?}", row); + }); + let subscribe_more_result = test_counter.add_test("resubscribe-more-matches"); + once_on_subscription_applied(move || { + let run_checks = || { + assert_eq_or_bail!(256, OneU8::count()); + Ok(()) + }; + subscribe_more_result(run_checks()); + }); + let subscribe_result = test_counter.add_test("resubscribe-again"); + subscribe_result(subscribe(&["SELECT * FROM OneU8"])); + test_counter.wait_for_all(); +} + +/// Once we determine appropriate semantics for in-process re-connecting, +/// this test will verify it. +fn exec_reconnect() { + todo!() +} + +/// Part of the `reauth` test, this connects to Spacetime to get new credentials, +/// and saves them to a file. +fn exec_reauth_part_1() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let connect_result = test_counter.add_test("connect"); + let save_result = test_counter.add_test("save-credentials"); + + once_on_connect(|creds| { + save_result(save_credentials(".spacetime_rust_sdk_test", creds)); + }); + + connect_result(connect(LOCALHOST, &name, None)); + + test_counter.wait_for_all(); +} + +/// Part of the `reauth` test, this loads credentials from a file, +/// and passes them to `connect`. +/// +/// Must run after `exec_reauth_part_1`. +fn exec_reauth_part_2() { + let test_counter = TestCounter::new(); + let name = db_name_or_panic(); + + let connect_result = test_counter.add_test("connect"); + let creds_match_result = test_counter.add_test("credentials-match"); + + let creds = load_credentials(".spacetime_rust_sdk_test") + .expect("Failed to load credentials") + .expect("Expected credentials but found none"); + + let creds_dup = creds.clone(); + + once_on_connect(move |received_creds| { + let run_checks = || { + assert_eq_or_bail!(creds_dup, *received_creds); + Ok(()) + }; + + creds_match_result(run_checks()); + }); + + connect_result(connect(LOCALHOST, &name, Some(creds))); + + test_counter.wait_for_all(); +} diff --git a/crates/sdk/tests/test-client/src/pk_test_table.rs b/crates/sdk/tests/test-client/src/pk_test_table.rs new file mode 100644 index 0000000000..9e8784b011 --- /dev/null +++ b/crates/sdk/tests/test-client/src/pk_test_table.rs @@ -0,0 +1,311 @@ +use crate::module_bindings::*; +use crate::test_counter::TestCounter; +use anyhow::anyhow; +use spacetimedb_sdk::table::TableWithPrimaryKey; +use std::sync::Arc; + +pub trait PkTestTable: TableWithPrimaryKey { + fn as_value(&self) -> i32; + + fn from_key_value(k: Self::PrimaryKey, v: i32) -> Self; + + fn is_insert_reducer_event(event: &Self::ReducerEvent) -> bool; + fn is_update_reducer_event(event: &Self::ReducerEvent) -> bool; + fn is_delete_reducer_event(event: &Self::ReducerEvent) -> bool; + + fn insert(k: Self::PrimaryKey, v: i32); + fn update(k: Self::PrimaryKey, v: i32); + fn delete(k: Self::PrimaryKey); +} + +pub fn insert_update_delete_one( + test_counter: &Arc, + key: T::PrimaryKey, + initial_value: i32, + update_value: i32, +) where + T::PrimaryKey: std::fmt::Debug + Send + 'static, +{ + let mut insert_result = Some(test_counter.add_test(format!("insert-{}", T::TABLE_NAME))); + let mut update_result = Some(test_counter.add_test(format!("update-{}", T::TABLE_NAME))); + let mut delete_result = Some(test_counter.add_test(format!("delete-{}", T::TABLE_NAME))); + + let mut on_delete = { + let key_dup = key.clone(); + Some(move |row: &T, reducer_event: Option<&T::ReducerEvent>| { + if delete_result.is_some() { + let run_checks = || { + if row.primary_key() != &key_dup || row.as_value() != update_value { + anyhow::bail!( + "Unexpected row value. Expected ({:?}, {}) but found {:?}", + key_dup, + update_value, + row + ); + } + reducer_event + .ok_or(anyhow!("Expected a reducer event, but found None.")) + .map(T::is_delete_reducer_event) + .and_then(|is_good| is_good.then_some(()).ok_or(anyhow!("Unexpected ReducerEvent variant.")))?; + Ok(()) + }; + + (delete_result.take().unwrap())(run_checks()); + } + }) + }; + + let mut on_update = { + let key_dup = key.clone(); + Some(move |old: &T, new: &T, reducer_event: Option<&T::ReducerEvent>| { + if update_result.is_some() { + let run_checks = || { + if old.primary_key() != &key_dup || old.as_value() != initial_value { + anyhow::bail!( + "Unexpected old row value. Expected ({:?}, {}) but found {:?}", + key_dup, + initial_value, + old, + ); + } + if new.primary_key() != &key_dup || new.as_value() != update_value { + anyhow::bail!( + "Unexpected new row value. Expected ({:?}, {}) but found {:?}", + key_dup, + update_value, + new, + ); + } + reducer_event + .ok_or(anyhow!("Expected a reducer event, but found None.")) + .map(T::is_update_reducer_event) + .and_then(|is_good| is_good.then_some(()).ok_or(anyhow!("Unexpected ReducerEvent variant.")))?; + Ok(()) + }; + + (update_result.take().unwrap())(run_checks()); + + T::on_delete(on_delete.take().unwrap()); + + T::delete(key_dup.clone()); + } + }) + }; + + let key_dup = key.clone(); + + T::on_insert(move |row, reducer_event| { + if insert_result.is_some() { + let run_checks = || { + if row.primary_key() != &key_dup || row.as_value() != initial_value { + anyhow::bail!( + "Unexpected row value. Expected ({:?}, {}) but found {:?}", + key_dup, + initial_value, + row + ); + } + reducer_event + .ok_or(anyhow!("Expected a reducer event, but found None.")) + .map(T::is_insert_reducer_event) + .and_then(|is_good| is_good.then_some(()).ok_or(anyhow!("Unexpected ReducerEvent variant.")))?; + + Ok(()) + }; + + (insert_result.take().unwrap())(run_checks()); + + T::on_update(on_update.take().unwrap()); + + T::update(key_dup.clone(), update_value); + } + }); + + T::insert(key, initial_value); +} + +macro_rules! impl_pk_test_table { + ($table:ty { + Key = $key:ty; + key_field_name = $field_name:ident; + insert_reducer = $insert_reducer:ident; + insert_reducer_event = $insert_reducer_event:ident; + delete_reducer = $delete_reducer:ident; + delete_reducer_event = $delete_reducer_event:ident; + update_reducer = $update_reducer:ident; + update_reducer_event = $update_reducer_event:ident; + }) => { + impl PkTestTable for $table { + fn as_value(&self) -> i32 { + self.data + } + + fn from_key_value(key: Self::PrimaryKey, value: i32) -> Self { + Self { + $field_name: key, + data: value, + } + } + + fn is_insert_reducer_event(event: &Self::ReducerEvent) -> bool { + matches!(event, ReducerEvent::$insert_reducer_event(_)) + } + fn is_delete_reducer_event(event: &Self::ReducerEvent) -> bool { + matches!(event, ReducerEvent::$delete_reducer_event(_)) + } + fn is_update_reducer_event(event: &Self::ReducerEvent) -> bool { + matches!(event, ReducerEvent::$update_reducer_event(_)) + } + + fn insert(key: Self::PrimaryKey, value: i32) { + $insert_reducer(key, value); + } + fn delete(key: Self::PrimaryKey) { + $delete_reducer(key); + } + fn update(key: Self::PrimaryKey, new_value: i32) { + $update_reducer(key, new_value); + } + } + }; + ($($table:ty { $($stuff:tt)* })*) => { + $(impl_pk_test_table!($table { $($stuff)* });)* + }; +} + +impl_pk_test_table! { + PkU8 { + Key = u8; + key_field_name = n; + insert_reducer = insert_pk_u_8; + insert_reducer_event = InsertPkU8; + delete_reducer = delete_pk_u_8; + delete_reducer_event = DeletePkU8; + update_reducer = update_pk_u_8; + update_reducer_event = UpdatePkU8; + } + PkU16 { + Key = u16; + key_field_name = n; + insert_reducer = insert_pk_u_16; + insert_reducer_event = InsertPkU16; + delete_reducer = delete_pk_u_16; + delete_reducer_event = DeletePkU16; + update_reducer = update_pk_u_16; + update_reducer_event = UpdatePkU16; + } + PkU32 { + Key = u32; + key_field_name = n; + insert_reducer = insert_pk_u_32; + insert_reducer_event = InsertPkU32; + delete_reducer = delete_pk_u_32; + delete_reducer_event = DeletePkU32; + update_reducer = update_pk_u_32; + update_reducer_event = UpdatePkU32; + } + PkU64 { + Key = u64; + key_field_name = n; + insert_reducer = insert_pk_u_64; + insert_reducer_event = InsertPkU64; + delete_reducer = delete_pk_u_64; + delete_reducer_event = DeletePkU64; + update_reducer = update_pk_u_64; + update_reducer_event = UpdatePkU64; + } + PkU128 { + Key = u128; + key_field_name = n; + insert_reducer = insert_pk_u_128; + insert_reducer_event = InsertPkU128; + delete_reducer = delete_pk_u_128; + delete_reducer_event = DeletePkU128; + update_reducer = update_pk_u_128; + update_reducer_event = UpdatePkU128; + } + + PkI8 { + Key = i8; + key_field_name = n; + insert_reducer = insert_pk_i_8; + insert_reducer_event = InsertPkI8; + delete_reducer = delete_pk_i_8; + delete_reducer_event = DeletePkI8; + update_reducer = update_pk_i_8; + update_reducer_event = UpdatePkI8; + } + PkI16 { + Key = i16; + key_field_name = n; + insert_reducer = insert_pk_i_16; + insert_reducer_event = InsertPkI16; + delete_reducer = delete_pk_i_16; + delete_reducer_event = DeletePkI16; + update_reducer = update_pk_i_16; + update_reducer_event = UpdatePkI16; + } + PkI32 { + Key = i32; + key_field_name = n; + insert_reducer = insert_pk_i_32; + insert_reducer_event = InsertPkI32; + delete_reducer = delete_pk_i_32; + delete_reducer_event = DeletePkI32; + update_reducer = update_pk_i_32; + update_reducer_event = UpdatePkI32; + } + PkI64 { + Key = i64; + key_field_name = n; + insert_reducer = insert_pk_i_64; + insert_reducer_event = InsertPkI64; + delete_reducer = delete_pk_i_64; + delete_reducer_event = DeletePkI64; + update_reducer = update_pk_i_64; + update_reducer_event = UpdatePkI64; + } + PkI128 { + Key = i128; + key_field_name = n; + insert_reducer = insert_pk_i_128; + insert_reducer_event = InsertPkI128; + delete_reducer = delete_pk_i_128; + delete_reducer_event = DeletePkI128; + update_reducer = update_pk_i_128; + update_reducer_event = UpdatePkI128; + } + + PkBool { + Key = bool; + key_field_name = b; + insert_reducer = insert_pk_bool; + insert_reducer_event = InsertPkBool; + delete_reducer = delete_pk_bool; + delete_reducer_event = DeletePkBool; + update_reducer = update_pk_bool; + update_reducer_event = UpdatePkBool; + } + + PkString { + Key = String; + key_field_name = s; + insert_reducer = insert_pk_string; + insert_reducer_event = InsertPkString; + delete_reducer = delete_pk_string; + delete_reducer_event = DeletePkString; + update_reducer = update_pk_string; + update_reducer_event = UpdatePkString; + } + + PkIdentity { + Key = Identity; + key_field_name = i; + insert_reducer = insert_pk_identity; + insert_reducer_event = InsertPkIdentity; + delete_reducer = delete_pk_identity; + delete_reducer_event = DeletePkIdentity; + update_reducer = update_pk_identity; + update_reducer_event = UpdatePkIdentity; + } +} diff --git a/crates/sdk/tests/test-client/src/simple_test_table.rs b/crates/sdk/tests/test-client/src/simple_test_table.rs new file mode 100644 index 0000000000..94c00c44e7 --- /dev/null +++ b/crates/sdk/tests/test-client/src/simple_test_table.rs @@ -0,0 +1,343 @@ +use crate::module_bindings::*; +use crate::test_counter::TestCounter; +use anyhow::anyhow; +use spacetimedb_sdk::{identity::Identity, table::TableType}; +use std::sync::Arc; + +pub trait SimpleTestTable: TableType { + type Contents: Clone + Send + Sync + PartialEq + std::fmt::Debug + 'static; + + fn as_contents(&self) -> &Self::Contents; + fn from_contents(contents: Self::Contents) -> Self; + + fn is_insert_reducer_event(event: &Self::ReducerEvent) -> bool; + + fn insert(contents: Self::Contents); +} + +macro_rules! impl_simple_test_table { + ($table:ty { + Contents = $contents:ty; + field_name = $field_name:ident; + insert_reducer = $insert_reducer:ident; + insert_reducer_event = $insert_reducer_event:ident; + }) => { + impl SimpleTestTable for $table { + type Contents = $contents; + + fn as_contents(&self) -> &Self::Contents { + &self.$field_name + } + + fn from_contents(contents: Self::Contents) -> Self { + Self { + $field_name: contents, + } + } + + fn is_insert_reducer_event(event: &Self::ReducerEvent) -> bool { + matches!(event, ReducerEvent::$insert_reducer_event(_)) + } + + fn insert(contents: Self::Contents) { + $insert_reducer(contents); + } + } + }; + ($($table:ty { $($stuff:tt)* })*) => { + $(impl_simple_test_table!($table { $($stuff)* });)* + }; +} + +impl_simple_test_table! { + OneU8 { + Contents = u8; + field_name = n; + insert_reducer = insert_one_u_8; + insert_reducer_event = InsertOneU8; + } + OneU16 { + Contents = u16; + field_name = n; + insert_reducer = insert_one_u_16; + insert_reducer_event = InsertOneU16; + } + OneU32 { + Contents = u32; + field_name = n; + insert_reducer = insert_one_u_32; + insert_reducer_event = InsertOneU32; + } + OneU64 { + Contents = u64; + field_name = n; + insert_reducer = insert_one_u_64; + insert_reducer_event = InsertOneU64; + } + OneU128 { + Contents = u128; + field_name = n; + insert_reducer = insert_one_u_128; + insert_reducer_event = InsertOneU128; + } + + OneI8 { + Contents = i8; + field_name = n; + insert_reducer = insert_one_i_8; + insert_reducer_event = InsertOneI8; + } + OneI16 { + Contents = i16; + field_name = n; + insert_reducer = insert_one_i_16; + insert_reducer_event = InsertOneI16; + } + OneI32 { + Contents = i32; + field_name = n; + insert_reducer = insert_one_i_32; + insert_reducer_event = InsertOneI32; + } + OneI64 { + Contents = i64; + field_name = n; + insert_reducer = insert_one_i_64; + insert_reducer_event = InsertOneI64; + } + OneI128 { + Contents = i128; + field_name = n; + insert_reducer = insert_one_i_128; + insert_reducer_event = InsertOneI128; + } + + OneF32 { + Contents = f32; + field_name = f; + insert_reducer = insert_one_f_32; + insert_reducer_event = InsertOneF32; + } + OneF64 { + Contents = f64; + field_name = f; + insert_reducer = insert_one_f_64; + insert_reducer_event = InsertOneF64; + } + + OneBool { + Contents = bool; + field_name = b; + insert_reducer = insert_one_bool; + insert_reducer_event = InsertOneBool; + } + + OneString { + Contents = String; + field_name = s; + insert_reducer = insert_one_string; + insert_reducer_event = InsertOneString; + } + + OneIdentity { + Contents = Identity; + field_name = i; + insert_reducer = insert_one_identity; + insert_reducer_event = InsertOneIdentity; + } + + OneSimpleEnum { + Contents = SimpleEnum; + field_name = e; + insert_reducer = insert_one_simple_enum; + insert_reducer_event = InsertOneSimpleEnum; + } + OneEnumWithPayload { + Contents = EnumWithPayload; + field_name = e; + insert_reducer = insert_one_enum_with_payload; + insert_reducer_event = InsertOneEnumWithPayload; + } + + OneUnitStruct { + Contents = UnitStruct; + field_name = s; + insert_reducer = insert_one_unit_struct; + insert_reducer_event = InsertOneUnitStruct; + } + OneByteStruct { + Contents = ByteStruct; + field_name = s; + insert_reducer = insert_one_byte_struct; + insert_reducer_event = InsertOneByteStruct; + } + OneEveryPrimitiveStruct { + Contents = EveryPrimitiveStruct; + field_name = s; + insert_reducer = insert_one_every_primitive_struct; + insert_reducer_event = InsertOneEveryPrimitiveStruct; + } + OneEveryVecStruct { + Contents = EveryVecStruct; + field_name = s; + insert_reducer = insert_one_every_vec_struct; + insert_reducer_event = InsertOneEveryVecStruct; + } + + VecU8 { + Contents = Vec; + field_name = n; + insert_reducer = insert_vec_u_8; + insert_reducer_event = InsertVecU8; + } + VecU16 { + Contents = Vec; + field_name = n; + insert_reducer = insert_vec_u_16; + insert_reducer_event = InsertVecU16; + } + VecU32 { + Contents = Vec; + field_name = n; + insert_reducer = insert_vec_u_32; + insert_reducer_event = InsertVecU32; + } + VecU64 { + Contents = Vec; + field_name = n; + insert_reducer = insert_vec_u_64; + insert_reducer_event = InsertVecU64; + } + VecU128 { + Contents = Vec; + field_name = n; + insert_reducer = insert_vec_u_128; + insert_reducer_event = InsertVecU128; + } + + VecI8 { + Contents = Vec; + field_name = n; + insert_reducer = insert_vec_i_8; + insert_reducer_event = InsertVecI8; + } + VecI16 { + Contents = Vec; + field_name = n; + insert_reducer = insert_vec_i_16; + insert_reducer_event = InsertVecI16; + } + VecI32 { + Contents = Vec; + field_name = n; + insert_reducer = insert_vec_i_32; + insert_reducer_event = InsertVecI32; + } + VecI64 { + Contents = Vec; + field_name = n; + insert_reducer = insert_vec_i_64; + insert_reducer_event = InsertVecI64; + } + VecI128 { + Contents = Vec; + field_name = n; + insert_reducer = insert_vec_i_128; + insert_reducer_event = InsertVecI128; + } + + VecF32 { + Contents = Vec; + field_name = f; + insert_reducer = insert_vec_f_32; + insert_reducer_event = InsertVecF32; + } + VecF64 { + Contents = Vec; + field_name = f; + insert_reducer = insert_vec_f_64; + insert_reducer_event = InsertVecF64; + } + + VecBool { + Contents = Vec; + field_name = b; + insert_reducer = insert_vec_bool; + insert_reducer_event = InsertVecBool; + } + + VecString { + Contents = Vec; + field_name = s; + insert_reducer = insert_vec_string; + insert_reducer_event = InsertVecString; + } + + VecIdentity { + Contents = Vec; + field_name = i; + insert_reducer = insert_vec_identity; + insert_reducer_event = InsertVecIdentity; + } + + VecSimpleEnum { + Contents = Vec; + field_name = e; + insert_reducer = insert_vec_simple_enum; + insert_reducer_event = InsertVecSimpleEnum; + } + VecEnumWithPayload { + Contents = Vec; + field_name = e; + insert_reducer = insert_vec_enum_with_payload; + insert_reducer_event = InsertVecEnumWithPayload; + } + + VecUnitStruct { + Contents = Vec; + field_name = s; + insert_reducer = insert_vec_unit_struct; + insert_reducer_event = InsertVecUnitStruct; + } + VecByteStruct { + Contents = Vec; + field_name = s; + insert_reducer = insert_vec_byte_struct; + insert_reducer_event = InsertVecByteStruct; + } + VecEveryPrimitiveStruct { + Contents = Vec; + field_name = s; + insert_reducer = insert_vec_every_primitive_struct; + insert_reducer_event = InsertVecEveryPrimitiveStruct; + } + VecEveryVecStruct { + Contents = Vec; + field_name = s; + insert_reducer = insert_vec_every_vec_struct; + insert_reducer_event = InsertVecEveryVecStruct; + } +} + +pub fn insert_one(test_counter: &Arc, value: T::Contents) { + let mut result = Some(test_counter.add_test(format!("insert-{}", T::TABLE_NAME))); + let value_dup = value.clone(); + T::on_insert(move |row, reducer_event| { + if result.is_some() { + let run_checks = || { + if row.as_contents() != &value_dup { + anyhow::bail!("Unexpected row value. Expected {:?} but found {:?}", value_dup, row); + } + reducer_event + .ok_or(anyhow!("Expected a reducer event, but found None.")) + .map(T::is_insert_reducer_event) + .and_then(|is_good| is_good.then_some(()).ok_or(anyhow!("Unexpected ReducerEvent variant.")))?; + + Ok(()) + }; + (result.take().unwrap())(run_checks()); + } + }); + + T::insert(value); +} diff --git a/crates/sdk/tests/test-client/src/test_counter.rs b/crates/sdk/tests/test-client/src/test_counter.rs new file mode 100644 index 0000000000..3196600019 --- /dev/null +++ b/crates/sdk/tests/test-client/src/test_counter.rs @@ -0,0 +1,99 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, Condvar, Mutex}, + time::Duration, +}; + +#[derive(Default)] +struct TestCounterInner { + /// Maps test names to their outcomes + outcomes: HashMap>, + /// Set of tests which have started. + registered: HashSet, +} + +pub struct TestCounter { + inner: Mutex, + wait_until_done: Condvar, +} + +impl Default for TestCounter { + fn default() -> Self { + TestCounter { + inner: Mutex::new(TestCounterInner::default()), + wait_until_done: Condvar::new(), + } + } +} + +impl TestCounter { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + #[must_use] + pub fn add_test( + self: &Arc, + test_name: impl Into + Clone + std::fmt::Display + Send + 'static, + ) -> Box) + Send + 'static> { + { + let mut lock = self.inner.lock().expect("TestCounterInner Mutex is poisoned"); + if !lock.registered.insert(test_name.clone().into()) { + panic!("Duplicate test name: {}", test_name); + } + } + let dup = Arc::clone(self); + + Box::new(move |outcome| { + let mut lock = dup.inner.lock().expect("TestCounterInner Mutex is poisoned"); + lock.outcomes.insert(test_name.into(), outcome); + dup.wait_until_done.notify_all(); + }) + } + + pub fn wait_for_all(&self) { + let lock = self.inner.lock().expect("TestCounterInner Mutex is poisoned"); + let (lock, timeout_result) = self + .wait_until_done + .wait_timeout_while(lock, Duration::from_secs(5), |inner| { + inner.outcomes.len() != inner.registered.len() + }) + .expect("TestCounterInner Mutex is poisoned"); + if timeout_result.timed_out() { + let mut timeout_count = 0; + let mut failed_count = 0; + for test in lock.registered.iter() { + match lock.outcomes.get(test) { + None => { + timeout_count += 1; + println!("TIMEOUT: {}", test); + } + Some(Err(e)) => { + failed_count += 1; + println!("FAILED: {}:\n\t{:?}\n", test, e); + } + Some(Ok(())) => { + println!("PASSED: {}", test); + } + } + } + panic!("{} tests timed out and {} tests failed", timeout_count, failed_count) + } else { + let mut failed_count = 0; + for (test, outcome) in lock.outcomes.iter() { + match outcome { + Ok(()) => println!("PASSED: {}", test), + Err(e) => { + failed_count += 1; + println!("FAILED: {}:\n\t{:?}\n", test, e); + } + } + } + if failed_count != 0 { + panic!("{} tests failed", failed_count); + } else { + println!("All tests passed"); + } + } + } +} diff --git a/crates/sdk/tests/test-client/src/unique_test_table.rs b/crates/sdk/tests/test-client/src/unique_test_table.rs new file mode 100644 index 0000000000..15788806c0 --- /dev/null +++ b/crates/sdk/tests/test-client/src/unique_test_table.rs @@ -0,0 +1,238 @@ +use crate::module_bindings::*; +use crate::test_counter::TestCounter; +use anyhow::anyhow; +use spacetimedb_sdk::{identity::Identity, table::TableType}; +use std::sync::Arc; + +pub trait UniqueTestTable: TableType { + type Key: Clone + Send + Sync + PartialEq + std::fmt::Debug + 'static; + + fn as_key(&self) -> &Self::Key; + fn as_value(&self) -> i32; + + fn from_key_value(k: Self::Key, v: i32) -> Self; + + fn is_insert_reducer_event(event: &Self::ReducerEvent) -> bool; + fn is_delete_reducer_event(event: &Self::ReducerEvent) -> bool; + + fn insert(k: Self::Key, v: i32); + fn delete(k: Self::Key); +} + +pub fn insert_then_delete_one(test_counter: &Arc, key: T::Key, value: i32) { + let mut insert_result = Some(test_counter.add_test(format!("insert-{}", T::TABLE_NAME))); + let mut delete_result = Some(test_counter.add_test(format!("delete-{}", T::TABLE_NAME))); + + let mut on_delete = { + let key_dup = key.clone(); + Some(move |row: &T, reducer_event: Option<&T::ReducerEvent>| { + if delete_result.is_some() { + let run_checks = || { + if row.as_key() != &key_dup || row.as_value() != value { + anyhow::bail!( + "Unexpected row value. Expected ({:?}, {}) but found {:?}", + key_dup, + value, + row + ); + } + reducer_event + .ok_or(anyhow!("Expected a reducer event, but found None.")) + .map(T::is_delete_reducer_event) + .and_then(|is_good| is_good.then_some(()).ok_or(anyhow!("Unexpected ReducerEvent variant.")))?; + Ok(()) + }; + + (delete_result.take().unwrap())(run_checks()); + } + }) + }; + + let key_dup = key.clone(); + + T::on_insert(move |row, reducer_event| { + if insert_result.is_some() { + let run_checks = || { + if row.as_key() != &key_dup || row.as_value() != value { + anyhow::bail!( + "Unexpected row value. Expected ({:?}, {}) but found {:?}", + key_dup, + value, + row + ); + } + reducer_event + .ok_or(anyhow!("Expected a reducer event, but found None.")) + .map(T::is_insert_reducer_event) + .and_then(|is_good| is_good.then_some(()).ok_or(anyhow!("Unexpected ReducerEvent variant.")))?; + + Ok(()) + }; + + (insert_result.take().unwrap())(run_checks()); + + T::on_delete(on_delete.take().unwrap()); + + T::delete(key_dup.clone()); + } + }); + + T::insert(key, value); +} + +macro_rules! impl_unique_test_table { + ($table:ty { + Key = $key:ty; + key_field_name = $field_name:ident; + insert_reducer = $insert_reducer:ident; + insert_reducer_event = $insert_reducer_event:ident; + delete_reducer = $delete_reducer:ident; + delete_reducer_event = $delete_reducer_event:ident; + }) => { + impl UniqueTestTable for $table { + type Key = $key; + + fn as_key(&self) -> &Self::Key { + &self.$field_name + } + fn as_value(&self) -> i32 { + self.data + } + + fn from_key_value(key: Self::Key, value: i32) -> Self { + Self { + $field_name: key, + data: value, + } + } + + fn is_insert_reducer_event(event: &Self::ReducerEvent) -> bool { + matches!(event, ReducerEvent::$insert_reducer_event(_)) + } + fn is_delete_reducer_event(event: &Self::ReducerEvent) -> bool { + matches!(event, ReducerEvent::$delete_reducer_event(_)) + } + + fn insert(key: Self::Key, value: i32) { + $insert_reducer(key, value); + } + fn delete(key: Self::Key) { + $delete_reducer(key); + } + } + }; + ($($table:ty { $($stuff:tt)* })*) => { + $(impl_unique_test_table!($table { $($stuff)* });)* + }; +} + +impl_unique_test_table! { + UniqueU8 { + Key = u8; + key_field_name = n; + insert_reducer = insert_unique_u_8; + insert_reducer_event = InsertUniqueU8; + delete_reducer = delete_unique_u_8; + delete_reducer_event = DeleteUniqueU8; + } + UniqueU16 { + Key = u16; + key_field_name = n; + insert_reducer = insert_unique_u_16; + insert_reducer_event = InsertUniqueU16; + delete_reducer = delete_unique_u_16; + delete_reducer_event = DeleteUniqueU16; + } + UniqueU32 { + Key = u32; + key_field_name = n; + insert_reducer = insert_unique_u_32; + insert_reducer_event = InsertUniqueU32; + delete_reducer = delete_unique_u_32; + delete_reducer_event = DeleteUniqueU32; + } + UniqueU64 { + Key = u64; + key_field_name = n; + insert_reducer = insert_unique_u_64; + insert_reducer_event = InsertUniqueU64; + delete_reducer = delete_unique_u_64; + delete_reducer_event = DeleteUniqueU64; + } + UniqueU128 { + Key = u128; + key_field_name = n; + insert_reducer = insert_unique_u_128; + insert_reducer_event = InsertUniqueU128; + delete_reducer = delete_unique_u_128; + delete_reducer_event = DeleteUniqueU128; + } + + UniqueI8 { + Key = i8; + key_field_name = n; + insert_reducer = insert_unique_i_8; + insert_reducer_event = InsertUniqueI8; + delete_reducer = delete_unique_i_8; + delete_reducer_event = DeleteUniqueI8; + } + UniqueI16 { + Key = i16; + key_field_name = n; + insert_reducer = insert_unique_i_16; + insert_reducer_event = InsertUniqueI16; + delete_reducer = delete_unique_i_16; + delete_reducer_event = DeleteUniqueI16; + } + UniqueI32 { + Key = i32; + key_field_name = n; + insert_reducer = insert_unique_i_32; + insert_reducer_event = InsertUniqueI32; + delete_reducer = delete_unique_i_32; + delete_reducer_event = DeleteUniqueI32; + } + UniqueI64 { + Key = i64; + key_field_name = n; + insert_reducer = insert_unique_i_64; + insert_reducer_event = InsertUniqueI64; + delete_reducer = delete_unique_i_64; + delete_reducer_event = DeleteUniqueI64; + } + UniqueI128 { + Key = i128; + key_field_name = n; + insert_reducer = insert_unique_i_128; + insert_reducer_event = InsertUniqueI128; + delete_reducer = delete_unique_i_128; + delete_reducer_event = DeleteUniqueI128; + } + + UniqueBool { + Key = bool; + key_field_name = b; + insert_reducer = insert_unique_bool; + insert_reducer_event = InsertUniqueBool; + delete_reducer = delete_unique_bool; + delete_reducer_event = DeleteUniqueBool; + } + + UniqueString { + Key = String; + key_field_name = s; + insert_reducer = insert_unique_string; + insert_reducer_event = InsertUniqueString; + delete_reducer = delete_unique_string; + delete_reducer_event = DeleteUniqueString; + } + + UniqueIdentity { + Key = Identity; + key_field_name = i; + insert_reducer = insert_unique_identity; + insert_reducer_event = InsertUniqueIdentity; + delete_reducer = delete_unique_identity; + delete_reducer_event = DeleteUniqueIdentity; + } +} diff --git a/crates/sdk/tests/test.rs b/crates/sdk/tests/test.rs new file mode 100644 index 0000000000..30a5b07866 --- /dev/null +++ b/crates/sdk/tests/test.rs @@ -0,0 +1,93 @@ +use spacetimedb_testing::sdk::Test; + +const MODULE: &str = "sdk-test"; +const CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/test-client"); + +fn make_test(subcommand: &str) -> Test { + Test::builder() + .with_name(subcommand) + .with_module(MODULE) + .with_client(CLIENT) + .with_language("rust") + .with_bindings_dir("src/module_bindings") + .with_compile_command("cargo build") + .with_run_command(format!("cargo run -- {}", subcommand)) + .build() +} + +#[test] +fn insert_primitive() { + make_test("insert_primitive").run(); +} + +#[test] +fn delete_primitive() { + make_test("delete_primitive").run(); +} + +#[test] +fn update_primitive() { + make_test("update_primitive").run(); +} + +#[test] +fn insert_identity() { + make_test("insert_identity").run(); +} + +#[test] +fn delete_identity() { + make_test("delete_identity").run(); +} + +#[test] +fn update_identity() { + make_test("delete_identity").run(); +} + +#[test] +fn on_reducer() { + make_test("on_reducer").run(); +} + +#[test] +fn fail_reducer() { + make_test("fail_reducer").run(); +} + +#[test] +fn insert_vec() { + make_test("insert_vec").run(); +} + +#[test] +fn insert_simple_enum() { + make_test("insert_simple_enum").run(); +} + +#[test] +fn insert_enum_with_payload() { + make_test("insert_enum_with_payload").run(); +} + +#[test] +fn insert_long_table() { + make_test("insert_long_table").run(); +} + +#[test] +fn resubscribe() { + make_test("resubscribe").run(); +} + +#[test] +#[should_panic] +fn should_fail() { + make_test("should_fail").run(); +} + +#[test] +fn reauth() { + make_test("reauth_part_1").run(); + make_test("reauth_part_2").run(); +} diff --git a/crates/testing/Cargo.toml b/crates/testing/Cargo.toml index 09d4744349..fca6ae128e 100644 --- a/crates/testing/Cargo.toml +++ b/crates/testing/Cargo.toml @@ -13,6 +13,9 @@ anyhow.workspace = true serde_json.workspace = true tokio.workspace = true wasmbin.workspace = true +duct.workspace = true +lazy_static.workspace = true +rand.workspace = true [dev-dependencies] serial_test.workspace = true diff --git a/crates/testing/src/lib.rs b/crates/testing/src/lib.rs index 50842351b2..6e47d5e80c 100644 --- a/crates/testing/src/lib.rs +++ b/crates/testing/src/lib.rs @@ -2,6 +2,7 @@ use spacetimedb::config::{FilesLocal, SpacetimeDbFiles}; use std::env; pub mod modules; +pub mod sdk; pub fn set_key_env_vars(paths: &FilesLocal) { let set_if_not_exist = |var, path| { diff --git a/crates/testing/src/modules.rs b/crates/testing/src/modules.rs index 495e277c08..78a78bb2e1 100644 --- a/crates/testing/src/modules.rs +++ b/crates/testing/src/modules.rs @@ -54,12 +54,12 @@ where }); } -fn module_path(name: &str) -> PathBuf { +pub(crate) fn module_path(name: &str) -> PathBuf { let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); root.join("../../modules").join(name) } -fn wasm_path(name: &str) -> PathBuf { +pub(crate) fn wasm_path(name: &str) -> PathBuf { module_path(name).join(format!( "target/wasm32-unknown-unknown/release/{}_module.wasm", name.replace('-', "_") diff --git a/crates/testing/src/sdk.rs b/crates/testing/src/sdk.rs new file mode 100644 index 0000000000..c175c68db4 --- /dev/null +++ b/crates/testing/src/sdk.rs @@ -0,0 +1,390 @@ +use duct::{cmd, Handle}; +use lazy_static::lazy_static; +use rand::distributions::{Alphanumeric, DistString}; +use std::{collections::HashSet, fs::create_dir_all, sync::Mutex}; + +use crate::modules::{compile, module_path, wasm_path}; + +struct StandaloneProcess { + handle: Handle, + num_using: usize, +} + +impl StandaloneProcess { + fn start() -> Self { + let handle = cmd!("spacetime", "start") + .stderr_to_stdout() + .stdout_capture() + .unchecked() + .start() + .expect("Failed to run `spacetime start`"); + + StandaloneProcess { handle, num_using: 1 } + } + + fn stop(&mut self) -> anyhow::Result<()> { + assert!(self.num_using == 0); + + self.handle.kill()?; + + Ok(()) + } + + fn running_or_err(&self) -> anyhow::Result<()> { + if let Some(output) = self + .handle + .try_wait() + .expect("Error from spacetime standalone subprocess") + { + let code = output.status; + let output = String::from_utf8_lossy(&output.stdout); + Err(anyhow::anyhow!( + "spacetime start exited unexpectedly. Exit status: {}. Output:\n{}", + code, + output, + )) + } else { + Ok(()) + } + } + + fn add_user(&mut self) -> anyhow::Result<()> { + self.running_or_err()?; + self.num_using += 1; + Ok(()) + } + + /// Returns true if the process was stopped because no one is using it. + fn sub_user(&mut self) -> anyhow::Result { + self.num_using -= 1; + if self.num_using == 0 { + self.stop()?; + Ok(true) + } else { + Ok(false) + } + } +} + +static STANDALONE_PROCESS: Mutex> = Mutex::new(None); + +/// An RAII handle on the `STANDALONE_PROCESS`. +/// +/// On construction, ensures that the `STANDALONE_PROCESS` is running. +/// +/// On drop, checks to see if it was the last `StandaloneHandle`, and if so, +/// terminates the `STANDALONE_PROCESS`. +pub struct StandaloneHandle { + _hidden: (), +} + +impl Default for StandaloneHandle { + fn default() -> Self { + let mut process = STANDALONE_PROCESS.lock().expect("STANDALONE_PROCESS Mutex is poisoned"); + if let Some(proc) = &mut *process { + proc.add_user() + .expect("Failed to add user for running spacetime standalone process"); + } else { + *process = Some(StandaloneProcess::start()); + } + StandaloneHandle { _hidden: () } + } +} + +impl Drop for StandaloneHandle { + fn drop(&mut self) { + let mut process = STANDALONE_PROCESS.lock().expect("STANDALONE_PROCESS Mutex is poisoned"); + if let Some(proc) = &mut *process { + if proc + .sub_user() + .expect("Failed to remove user for running spacetime standalone process") + { + *process = None; + } + } + } +} + +lazy_static! { + /// An exclusive lock which ensures we only run `spacetime generate` once for each target directory. + /// + /// Without this lock, if multiple `Test`s ran concurrently in the same process + /// with the same `client_project` and `generate_subdir`, + /// the test harness would run `spacetime generate` multiple times concurrently, + /// each of which would remove and re-populate the bindings directory, + /// potentially sweeping them out from under a compile or run process. + /// + /// This lock ensures that only one `spacetime generate` process runs at a time, + /// and the `HashSet` ensures that we run `spacetime generate` only once for each output directory. + /// + /// Circumstances where this will still break: + /// - If multiple tests want to use the same client_project/generate_subdir pair, + /// but for different modules' bindings, only one module's bindings will ever be generated. + /// If you need bindings for multiple different modules, put them in different subdirs. + /// - If multiple distinct test harness processes run concurrently, + /// they will encounter the race condition described above, + /// because the `BINDINGS_GENERATED` lock is not shared between harness processes. + /// Running multiple test harness processes concurrently will break anyways + /// because each will try to run `spacetime start` as a subprocess and will therefore + /// contend over port 3000. + /// Prefer constructing multiple `Test`s and `Test::run`ing them + /// from within the same harness process. + // + // I (pgoldman 2023-09-11) considered, as an alternative to this lock, + // having `Test::run` copy the `client_project` into a fresh temporary directory. + // That would be more complicated, as we'd need to re-write dependencies + // on the client language's SpacetimeDB SDK to use a local absolute path. + // Doing so portably across all our SDK languages seemed infeasible. + static ref BINDINGS_GENERATED: Mutex> = Mutex::new(HashSet::new()); +} + +pub struct Test { + /// A human-readable name for this test. + name: String, + + /// Must name a module in the SpacetimeDB/modules directory. + module_name: String, + + /// An arbitrary path to the client project. + client_project: String, + + /// A language suitable for the `spacetime generate` CLI command. + generate_language: String, + + /// A relative path within the `client_project` to place the module bindings. + /// + /// Usually `src/module_bindings` + generate_subdir: String, + + /// A shell command to compile the client project. + /// + /// Will run with access to the env var `SPACETIME_SDK_TEST_CLIENT_PROJECT` + /// bound to the `client_project` path. + compile_command: String, + + /// A shell command to run the client project. + /// + /// Will run with access to the env vars: + /// - `SPACETIME_SDK_TEST_CLIENT_PROJECT` bound to the `client_project` path. + /// - `SPACETIME_SDK_TEST_DB_ADDR` bound to the database address. + run_command: String, +} + +pub const TEST_MODULE_PROJECT_ENV_VAR: &str = "SPACETIME_SDK_TEST_MODULE_PROJECT"; +pub const TEST_DB_NAME_ENV_VAR: &str = "SPACETIME_SDK_TEST_DB_NAME"; +pub const TEST_CLIENT_PROJECT_ENV_VAR: &str = "SPACETIME_SDK_TEST_CLIENT_PROJECT"; + +impl Test { + pub fn builder() -> TestBuilder { + TestBuilder::default() + } + pub fn run(&self) { + let _handle = StandaloneHandle::default(); + + compile(&self.module_name); + + generate_bindings( + &self.generate_language, + &self.module_name, + &self.client_project, + &self.generate_subdir, + &self.name, + ); + + compile_client(&self.compile_command, &self.client_project, &self.name); + + let db_name = publish_module(&self.module_name, &self.name); + + run_client(&self.run_command, &self.client_project, &db_name, &self.name); + } +} + +fn status_ok_or_panic(output: std::process::Output, command: &str, test_name: &str) { + if !output.status.success() { + panic!( + "{}: Error running {:?}: exited with non-zero exit status {}. Output:\n{}", + test_name, + command, + output.status, + String::from_utf8_lossy(&output.stdout), + ); + } +} + +fn random_module_name() -> String { + Alphanumeric.sample_string(&mut rand::thread_rng(), 16) +} + +fn publish_module(module: &str, test_name: &str) -> String { + let name = random_module_name(); + let output = cmd!("spacetime", "publish", "--skip_clippy", name.clone(),) + .stderr_to_stdout() + .stdout_capture() + .dir(module_path(module)) + .unchecked() + .run() + .expect("Error running spacetime publish"); + + status_ok_or_panic(output, "spacetime publish", test_name); + + name +} + +fn generate_bindings(language: &str, module_name: &str, client_project: &str, generate_subdir: &str, test_name: &str) { + let generate_dir = format!("{}/{}", client_project, generate_subdir); + + let mut bindings_lock = BINDINGS_GENERATED.lock().expect("BINDINGS_GENERATED Mutex is poisoned"); + + // If we've already generated bindings in this directory, + // return early. + // Otherwise, we'll hold the lock for the duration of the subprocess, + // so other tests will wait before overwriting our output. + if !bindings_lock.insert(generate_dir.clone()) { + return; + } + + create_dir_all(&generate_dir).expect("Error creating generate subdir"); + let output = cmd!( + "spacetime", + "generate", + "--skip_clippy", + "--lang", + language, + "--wasm-file", + wasm_path(module_name), + "--out-dir", + generate_dir + ) + .stderr_to_stdout() + .stdout_capture() + .unchecked() + .run() + .expect("Error running spacetime generate"); + + status_ok_or_panic(output, "spacetime generate", test_name); +} + +fn split_command_string(command: &str) -> (&str, Vec<&str>) { + let mut iter = command.split(' '); + let exe = iter.next().expect("Command should have at least a program name"); + let args = iter.collect(); + (exe, args) +} + +fn compile_client(compile_command: &str, client_project: &str, test_name: &str) { + let (exe, args) = split_command_string(compile_command); + + let output = cmd(exe, args) + .dir(client_project) + .env(TEST_CLIENT_PROJECT_ENV_VAR, client_project) + .stderr_to_stdout() + .stdout_capture() + .unchecked() + .run() + .expect("Error running compile command"); + + status_ok_or_panic(output, compile_command, test_name); +} + +fn run_client(run_command: &str, client_project: &str, db_name: &str, test_name: &str) { + let (exe, args) = split_command_string(run_command); + + let output = cmd(exe, args) + .dir(client_project) + .env(TEST_CLIENT_PROJECT_ENV_VAR, client_project) + .env(TEST_DB_NAME_ENV_VAR, db_name) + .env("RUST_LOG", "warn") + .stderr_to_stdout() + .stdout_capture() + .unchecked() + .run() + .expect("Error running run command"); + + status_ok_or_panic(output, run_command, test_name); +} + +#[derive(Clone, Default)] +pub struct TestBuilder { + name: Option, + module_name: Option, + client_project: Option, + generate_language: Option, + generate_subdir: Option, + compile_command: Option, + run_command: Option, +} + +impl TestBuilder { + pub fn with_name(self, name: impl Into) -> Self { + TestBuilder { + name: Some(name.into()), + ..self + } + } + + pub fn with_module(self, module_name: impl Into) -> Self { + TestBuilder { + module_name: Some(module_name.into()), + ..self + } + } + + pub fn with_client(self, client_project: impl Into) -> Self { + TestBuilder { + client_project: Some(client_project.into()), + ..self + } + } + + pub fn with_language(self, generate_language: impl Into) -> Self { + TestBuilder { + generate_language: Some(generate_language.into()), + ..self + } + } + + pub fn with_bindings_dir(self, generate_subdir: impl Into) -> Self { + TestBuilder { + generate_subdir: Some(generate_subdir.into()), + ..self + } + } + + pub fn with_compile_command(self, compile_command: impl Into) -> Self { + TestBuilder { + compile_command: Some(compile_command.into()), + ..self + } + } + + pub fn with_run_command(self, run_command: impl Into) -> Self { + TestBuilder { + run_command: Some(run_command.into()), + ..self + } + } + + pub fn build(self) -> Test { + Test { + name: self.name.expect("Supply a test name using TestBuilder::with_name"), + module_name: self + .module_name + .expect("Supply a module name using TestBuilder::with_module"), + client_project: self + .client_project + .expect("Supply a client project directory using TestBuilder::with_client"), + generate_language: self + .generate_language + .expect("Supply a client language using TestBuilder::with_language"), + generate_subdir: self + .generate_subdir + .expect("Supply a module_bindings subdirectory using TestBuilder::with_bindings_dir"), + compile_command: self + .compile_command + .expect("Supply a compile command using TestBuilder::with_compile_command"), + run_command: self + .run_command + .expect("Supply a run command using TestBuilder::with_run_command"), + } + } +} diff --git a/modules/sdk-test/.gitignore b/modules/sdk-test/.gitignore new file mode 100644 index 0000000000..31b13f058a --- /dev/null +++ b/modules/sdk-test/.gitignore @@ -0,0 +1,17 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Spacetime ignore +/.spacetime \ No newline at end of file diff --git a/modules/sdk-test/Cargo.toml b/modules/sdk-test/Cargo.toml new file mode 100644 index 0000000000..d578d5d1dd --- /dev/null +++ b/modules/sdk-test/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "sdk-test-module" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = { path = "../../crates/bindings", version = "0.6.1" } +log = "0.4" diff --git a/modules/sdk-test/src/lib.rs b/modules/sdk-test/src/lib.rs new file mode 100644 index 0000000000..6b9e35a1c8 --- /dev/null +++ b/modules/sdk-test/src/lib.rs @@ -0,0 +1,481 @@ +// Some of our tests include reducers with large numbers of arguments. +// This is on purpose. +// Due to a clippy bug (as of 2023-08-31), +// we cannot locally disable the lint around the definitions of those reducers, +// because the definitions are macro-generated, +// and clippy misunderstands `#[allow]` attributes in macro-expansions. +#![allow(clippy::too_many_arguments)] + +use spacetimedb::{spacetimedb, Identity, SpacetimeType}; + +#[derive(SpacetimeType)] +pub enum SimpleEnum { + Zero, + One, + Two, +} + +#[derive(SpacetimeType)] +pub enum EnumWithPayload { + U8(u8), + U16(u16), + U32(u32), + U64(u64), + U128(u128), + I8(i8), + I16(i16), + I32(i32), + I64(i64), + I128(i128), + Bool(bool), + F32(f32), + F64(f64), + Str(String), + Identity(Identity), + Bytes(Vec), + Ints(Vec), + Strings(Vec), + SimpleEnums(Vec), + // SpacetimeDB doesn't yet support recursive types in modules + // Recursive(Vec), +} + +#[derive(SpacetimeType)] +pub struct UnitStruct {} + +#[derive(SpacetimeType)] +pub struct ByteStruct { + b: u8, +} + +#[derive(SpacetimeType)] +pub struct EveryPrimitiveStruct { + a: u8, + b: u16, + c: u32, + d: u64, + e: u128, + f: i8, + g: i16, + h: i32, + i: i64, + j: i128, + k: bool, + l: f32, + m: f64, + n: String, + o: Identity, +} + +#[derive(SpacetimeType)] +pub struct EveryVecStruct { + a: Vec, + b: Vec, + c: Vec, + d: Vec, + e: Vec, + f: Vec, + g: Vec, + h: Vec, + i: Vec, + j: Vec, + k: Vec, + l: Vec, + m: Vec, + n: Vec, + o: Vec, +} + +/// Defines one or more tables, and optionally reducers alongside them. +/// +/// Each table specifier is: +/// +/// TableName { reducers... } fields...; +/// +/// where: +/// +/// - TableName is an identifier for the new table. +/// +/// - reducers... is a comma-separated list of reducer specifiers, which may be: +/// - insert reducer_name +/// Defines a reducer which takes an argument for each of the table's columns, and inserts a new row. +/// Not suitable for tables with unique constraints. +/// e.g. insert insert_my_table +/// - insert_or_panic reducer_name +/// Like insert, but for tables with unique constraints. Unwraps the output of `insert`. +/// e.g. insert_or_panic insert_my_table +/// - update_by reducer_name = update_method(field_name) +/// Defines a reducer which takes an argument for each of the table's columns, +/// and calls the update_method with the value of field_name as a first argument +/// to update an existing row. +/// e.g. update_by update_my_table = update_by_name(name) +/// - delete_by reducer_name = delete_method(field_name: field_type) +/// Defines a reducer which takes a single argument, and passes it to the delete_method +/// to delete a row. +/// e.g. delete_by delete_my_table = delete_by_name(name: String) +/// +/// - fields is a comma-separated list of field specifiers, which are optional attribues, +/// followed by a field name identifier and a type. +/// e.g. #[unique] name String +/// +/// A full table definition might be: +/// +/// MyTable { +/// insert_or_panic insert_my_table, +/// update_by update_my_table = update_by_name(name), +/// delete_by delete_my_table = delete_by_name(name: String), +/// } #[primarykey] name String, +/// #[autoinc] #[unique] id u32, +/// count i64; +// +// Internal rules are prefixed with @. +macro_rules! define_tables { + // Base case for `@impl_ops` recursion: no more ops to define. + (@impl_ops $name:ident { $(,)? } $($more:tt)*) => {}; + + // Define a reducer for tables without unique constraints, + // which inserts a row. + (@impl_ops $name:ident + { insert $insert:ident + $(, $($ops:tt)* )? } + $($field_name:ident $ty:ty),* $(,)*) => { + #[spacetimedb(reducer)] + pub fn $insert ($($field_name : $ty,)*) { + $name::insert($name { $($field_name,)* }); + } + + define_tables!(@impl_ops $name { $($($ops)*)? } $($field_name $ty,)*); + }; + + // Define a reducer for tables with unique constraints, + // which inserts a row, or panics with `expect` if the row violates a unique constraint. + (@impl_ops $name:ident + { insert_or_panic $insert:ident + $(, $($ops:tt)* )? } + $($field_name:ident $ty:ty),* $(,)*) => { + #[spacetimedb(reducer)] + pub fn $insert ($($field_name : $ty,)*) { + $name::insert($name { $($field_name,)* }).expect(concat!("Failed to insert row for table: ", stringify!($name))); + } + + define_tables!(@impl_ops $name { $($($ops)*)? } $($field_name $ty,)*); + }; + + // Define a reducer for tables with a unique field, + // which uses `$update_method` to update by that unique field. + (@impl_ops $name:ident + { update_by $update:ident = $update_method:ident($unique_field:ident) + $(, $($ops:tt)* )? } + $($field_name:ident $ty:ty),* $(,)*) => { + #[spacetimedb(reducer)] + pub fn $update ($($field_name : $ty,)*) { + let key = $unique_field.clone(); + $name::$update_method(&key, $name { $($field_name,)* }); + } + + define_tables!(@impl_ops $name { $($($ops)*)? } $($field_name $ty,)*); + }; + + // Define a reducer for tables with a unique field, + // which uses `$delete_method` to delete by that unique field. + (@impl_ops $name:ident + { delete_by $delete:ident = $delete_method:ident($unique_field:ident : $unique_ty:ty) + $(, $($ops:tt)*)? } + $($other_fields:tt)* ) => { + #[spacetimedb(reducer)] + pub fn $delete ($unique_field : $unique_ty) { + $name::$delete_method(&$unique_field); + } + + define_tables!(@impl_ops $name { $($($ops)*)? } $($other_fields)*); + }; + + // Define a table. + (@one $name:ident { $($ops:tt)* } $($(#[$attr:meta])* $field_name:ident $ty:ty),* $(,)*) => { + #[spacetimedb(table)] + pub struct $name { + $($(#[$attr])* pub $field_name : $ty,)* + } + + // Recursively implement reducers based on the `ops`. + define_tables!(@impl_ops $name { $($ops)* } $($field_name $ty,)*); + }; + + // "Public" interface: Define many tables. + ($($name:ident { $($ops:tt)* } $($(#[$attr:meta])* $field_name:ident $ty:ty),* $(,)*;)*) => { + // Define each table one-by-one, iteratively. + $(define_tables!(@one $name { $($ops)* } $($(#[$attr])* $field_name $ty,)*);)* + }; +} + +// Tables holding a single value. +define_tables! { + OneU8 { insert insert_one_u8 } n u8; + OneU16 { insert insert_one_u16 } n u16; + OneU32 { insert insert_one_u32 } n u32; + OneU64 { insert insert_one_u64 } n u64; + OneU128 { insert insert_one_u128 } n u128; + + OneI8 { insert insert_one_i8 } n i8; + OneI16 { insert insert_one_i16 } n i16; + OneI32 { insert insert_one_i32 } n i32; + OneI64 { insert insert_one_i64 } n i64; + OneI128 { insert insert_one_i128 } n i128; + + OneBool { insert insert_one_bool } b bool; + + OneF32 { insert insert_one_f32 } f f32; + OneF64 { insert insert_one_f64 } f f64; + + OneString { insert insert_one_string } s String; + + OneIdentity { insert insert_one_identity } i Identity; + + OneSimpleEnum { insert insert_one_simple_enum } e SimpleEnum; + OneEnumWithPayload { insert insert_one_enum_with_payload } e EnumWithPayload; + + OneUnitStruct { insert insert_one_unit_struct } s UnitStruct; + OneByteStruct { insert insert_one_byte_struct } s ByteStruct; + OneEveryPrimitiveStruct { insert insert_one_every_primitive_struct } s EveryPrimitiveStruct; + OneEveryVecStruct { insert insert_one_every_vec_struct } s EveryVecStruct; +} + +// Tables holding a Vec of various types. +define_tables! { + VecU8 { insert insert_vec_u8 } n Vec; + VecU16 { insert insert_vec_u16 } n Vec; + VecU32 { insert insert_vec_u32 } n Vec; + VecU64 { insert insert_vec_u64 } n Vec; + VecU128 { insert insert_vec_u128 } n Vec; + + VecI8 { insert insert_vec_i8 } n Vec; + VecI16 { insert insert_vec_i16 } n Vec; + VecI32 { insert insert_vec_i32 } n Vec; + VecI64 { insert insert_vec_i64 } n Vec; + VecI128 { insert insert_vec_i128 } n Vec; + + VecBool { insert insert_vec_bool } b Vec; + + VecF32 { insert insert_vec_f32 } f Vec; + VecF64 { insert insert_vec_f64 } f Vec; + + VecString { insert insert_vec_string } s Vec; + + VecIdentity { insert insert_vec_identity } i Vec; + + VecSimpleEnum { insert insert_vec_simple_enum } e Vec; + VecEnumWithPayload { insert insert_vec_enum_with_payload } e Vec; + + VecUnitStruct { insert insert_vec_unit_struct } s Vec; + VecByteStruct { insert insert_vec_byte_struct } s Vec; + VecEveryPrimitiveStruct { insert insert_vec_every_primitive_struct } s Vec; + VecEveryVecStruct { insert insert_vec_every_vec_struct } s Vec; +} + +// Tables mapping a unique, but non-pk, key to a boring i32 payload. +// This allows us to test delete events, and the semantically correct absence of update events. +define_tables! { + UniqueU8 { + insert_or_panic insert_unique_u8, + update_by update_unique_u8 = update_by_n(n), + delete_by delete_unique_u8 = delete_by_n(n: u8), + } #[unique] n u8, data i32; + + UniqueU16 { + insert_or_panic insert_unique_u16, + update_by update_unique_u16 = update_by_n(n), + delete_by delete_unique_u16 = delete_by_n(n: u16), + } #[unique] n u16, data i32; + + UniqueU32 { + insert_or_panic insert_unique_u32, + update_by update_unique_u32 = update_by_n(n), + delete_by delete_unique_u32 = delete_by_n(n: u32), + } #[unique] n u32, data i32; + + UniqueU64 { + insert_or_panic insert_unique_u64, + update_by update_unique_u64 = update_by_n(n), + delete_by delete_unique_u64 = delete_by_n(n: u64), + } #[unique] n u64, data i32; + + UniqueU128 { + insert_or_panic insert_unique_u128, + update_by update_unique_u128 = update_by_n(n), + delete_by delete_unique_u128 = delete_by_n(n: u128), + } #[unique] n u128, data i32; + + + UniqueI8 { + insert_or_panic insert_unique_i8, + update_by update_unique_i8 = update_by_n(n), + delete_by delete_unique_i8 = delete_by_n(n: i8), + } #[unique] n i8, data i32; + + + UniqueI16 { + insert_or_panic insert_unique_i16, + update_by update_unique_i16 = update_by_n(n), + delete_by delete_unique_i16 = delete_by_n(n: i16), + } #[unique] n i16, data i32; + + UniqueI32 { + insert_or_panic insert_unique_i32, + update_by update_unique_i32 = update_by_n(n), + delete_by delete_unique_i32 = delete_by_n(n: i32), + } #[unique] n i32, data i32; + + UniqueI64 { + insert_or_panic insert_unique_i64, + update_by update_unique_i64 = update_by_n(n), + delete_by delete_unique_i64 = delete_by_n(n: i64), + } #[unique] n i64, data i32; + + UniqueI128 { + insert_or_panic insert_unique_i128, + update_by update_unique_i128 = update_by_n(n), + delete_by delete_unique_i128 = delete_by_n(n: i128), + } #[unique] n i128, data i32; + + + UniqueBool { + insert_or_panic insert_unique_bool, + update_by update_unique_bool = update_by_b(b), + delete_by delete_unique_bool = delete_by_b(b: bool), + } #[unique] b bool, data i32; + + UniqueString { + insert_or_panic insert_unique_string, + update_by update_unique_string = update_by_s(s), + delete_by delete_unique_string = delete_by_s(s: String), + } #[unique] s String, data i32; + + UniqueIdentity { + insert_or_panic insert_unique_identity, + update_by update_unique_identity = update_by_i(i), + delete_by delete_unique_identity = delete_by_i(i: Identity), + } #[unique] i Identity, data i32; +} + +// Tables mapping a primary key to a boring i32 payload. +// This allows us to test update and delete events. +define_tables! { + PkU8 { + insert_or_panic insert_pk_u8, + update_by update_pk_u8 = update_by_n(n), + delete_by delete_pk_u8 = delete_by_n(n: u8), + } #[primarykey] n u8, data i32; + + PkU16 { + insert_or_panic insert_pk_u16, + update_by update_pk_u16 = update_by_n(n), + delete_by delete_pk_u16 = delete_by_n(n: u16), + } #[primarykey] n u16, data i32; + + PkU32 { + insert_or_panic insert_pk_u32, + update_by update_pk_u32 = update_by_n(n), + delete_by delete_pk_u32 = delete_by_n(n: u32), + } #[primarykey] n u32, data i32; + + PkU64 { + insert_or_panic insert_pk_u64, + update_by update_pk_u64 = update_by_n(n), + delete_by delete_pk_u64 = delete_by_n(n: u64), + } #[primarykey] n u64, data i32; + + PkU128 { + insert_or_panic insert_pk_u128, + update_by update_pk_u128 = update_by_n(n), + delete_by delete_pk_u128 = delete_by_n(n: u128), + } #[primarykey] n u128, data i32; + + + PkI8 { + insert_or_panic insert_pk_i8, + update_by update_pk_i8 = update_by_n(n), + delete_by delete_pk_i8 = delete_by_n(n: i8), + } #[primarykey] n i8, data i32; + + + PkI16 { + insert_or_panic insert_pk_i16, + update_by update_pk_i16 = update_by_n(n), + delete_by delete_pk_i16 = delete_by_n(n: i16), + } #[primarykey] n i16, data i32; + + PkI32 { + insert_or_panic insert_pk_i32, + update_by update_pk_i32 = update_by_n(n), + delete_by delete_pk_i32 = delete_by_n(n: i32), + } #[primarykey] n i32, data i32; + + PkI64 { + insert_or_panic insert_pk_i64, + update_by update_pk_i64 = update_by_n(n), + delete_by delete_pk_i64 = delete_by_n(n: i64), + } #[primarykey] n i64, data i32; + + PkI128 { + insert_or_panic insert_pk_i128, + update_by update_pk_i128 = update_by_n(n), + delete_by delete_pk_i128 = delete_by_n(n: i128), + } #[primarykey] n i128, data i32; + + + PkBool { + insert_or_panic insert_pk_bool, + update_by update_pk_bool = update_by_b(b), + delete_by delete_pk_bool = delete_by_b(b: bool), + } #[primarykey] b bool, data i32; + + PkString { + insert_or_panic insert_pk_string, + update_by update_pk_string = update_by_s(s), + delete_by delete_pk_string = delete_by_s(s: String), + } #[primarykey] s String, data i32; + + PkIdentity { + insert_or_panic insert_pk_identity, + update_by update_pk_identity = update_by_i(i), + delete_by delete_pk_identity = delete_by_i(i: Identity), + } #[primarykey] i Identity, data i32; +} + +// Some weird-looking tables. +define_tables! { + // A table with many fields, of many different types. + LargeTable { + insert insert_large_table, + } + a u8, + b u16, + c u32, + d u64, + e u128, + f i8, + g i16, + h i32, + i i64, + j i128, + k bool, + l f32, + m f64, + n String, + o SimpleEnum, + p EnumWithPayload, + q UnitStruct, + r ByteStruct, + s EveryPrimitiveStruct, + t EveryVecStruct, + ; + + // A table which holds instances of other table structs. + // This tests that we can use tables as types. + TableHoldsTable { + insert insert_table_holds_table, + } + a OneU8, + b VecU8, + ; +}