From fe657a3f689f48e12b9e38d8e0b3d14a85930cd0 Mon Sep 17 00:00:00 2001 From: Scott Robinson Date: Tue, 15 Sep 2026 06:30:13 +0000 Subject: [PATCH 1/2] test: port the plug-in repo's direct storage-trait integration suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original extenddb-cassandra-plugin repository carried 139 direct integration tests against the storage traits — real node, no HTTP server in between — of which the in-tree port had picked up only the four tag tests. The other 135 covered CRUD, Query/Scan semantics and pagination, transactions (TransactGet, TransactWrite rollback and idempotency, ledger operations), GSI/LSI physical schema and the async propagation workers, streams, access keys, accounts, users, groups, roles, policies, admin and settings stores, and backup/restore state. None of that had automated coverage in-tree. One test binary (direct_integration), one module per area, sharing tests/common/mod.rs — the in-tree descendant of the plug-in's helpers.rs, which is why the port is mostly mechanical: keyspace-prefix adaptation, the two CreateTableInput fields added since the fork, and the skip-without-Cassandra guard on all 139 tests so the serviceless workspace test job stays green. Four tests the inventory flagged as weak were strengthened rather than copied: the table lifecycle and settings tests printed outcomes instead of asserting them (the settings test also now restores the live control-plane knob it mutates, which used to leak past the test run); the pk-only delete-timestamp test carried dead bindings and now verifies the delete; and the sync-GSI test's TODO was hiding a real semantic difference — the plug-in engine wrote GSIs synchronously by default, in-tree the default is 10ms async propagation via a queue no test worker drains, so its "verify the write path executes without crashing" would never have caught a lost index write. It now pins the index to synchronous propagation (the same knob production routing reads) and asserts the index row is visible through the index-scan path. The suite runs in parallel: every test provisions its own account and keyspace. Wired into the Cassandra CI workflow after the existing serial suites. --- .github/workflows/integration-cassandra.yml | 5 + .../tests/direct/access_keys.rs | 264 +++ .../tests/direct/accounts.rs | 144 ++ .../tests/direct/admin_store.rs | 171 ++ .../tests/direct/authorization_store.rs | 353 ++++ .../tests/direct/backup_engine.rs | 383 +++++ .../tests/direct/cassandra_engine.rs | 303 ++++ .../tests/direct/delete_item.rs | 747 ++++++++ .../storage-cassandra/tests/direct/groups.rs | 182 ++ .../storage-cassandra/tests/direct/index.rs | 848 +++++++++ .../tests/direct/metadata_engine.rs | 152 ++ .../tests/direct/policies.rs | 84 + .../tests/direct/put_get_item.rs | 708 ++++++++ .../storage-cassandra/tests/direct/query.rs | 1509 +++++++++++++++++ .../storage-cassandra/tests/direct/roles.rs | 286 ++++ crates/storage-cassandra/tests/direct/scan.rs | 782 +++++++++ .../tests/direct/settings_store.rs | 110 ++ .../storage-cassandra/tests/direct/streams.rs | 637 +++++++ .../tests/direct/table_engine.rs | 152 ++ .../tests/direct/transact_get_items.rs | 448 +++++ .../tests/direct/transact_write_items.rs | 585 +++++++ .../tests/direct/transaction_ledger.rs | 296 ++++ .../storage-cassandra/tests/direct/users.rs | 426 +++++ .../tests/direct_integration.rs | 59 + 24 files changed, 9634 insertions(+) create mode 100644 crates/storage-cassandra/tests/direct/access_keys.rs create mode 100644 crates/storage-cassandra/tests/direct/accounts.rs create mode 100644 crates/storage-cassandra/tests/direct/admin_store.rs create mode 100644 crates/storage-cassandra/tests/direct/authorization_store.rs create mode 100644 crates/storage-cassandra/tests/direct/backup_engine.rs create mode 100644 crates/storage-cassandra/tests/direct/cassandra_engine.rs create mode 100644 crates/storage-cassandra/tests/direct/delete_item.rs create mode 100644 crates/storage-cassandra/tests/direct/groups.rs create mode 100644 crates/storage-cassandra/tests/direct/index.rs create mode 100644 crates/storage-cassandra/tests/direct/metadata_engine.rs create mode 100644 crates/storage-cassandra/tests/direct/policies.rs create mode 100644 crates/storage-cassandra/tests/direct/put_get_item.rs create mode 100644 crates/storage-cassandra/tests/direct/query.rs create mode 100644 crates/storage-cassandra/tests/direct/roles.rs create mode 100644 crates/storage-cassandra/tests/direct/scan.rs create mode 100644 crates/storage-cassandra/tests/direct/settings_store.rs create mode 100644 crates/storage-cassandra/tests/direct/streams.rs create mode 100644 crates/storage-cassandra/tests/direct/table_engine.rs create mode 100644 crates/storage-cassandra/tests/direct/transact_get_items.rs create mode 100644 crates/storage-cassandra/tests/direct/transact_write_items.rs create mode 100644 crates/storage-cassandra/tests/direct/transaction_ledger.rs create mode 100644 crates/storage-cassandra/tests/direct/users.rs create mode 100644 crates/storage-cassandra/tests/direct_integration.rs diff --git a/.github/workflows/integration-cassandra.yml b/.github/workflows/integration-cassandra.yml index 7c446558..4c3eebbd 100644 --- a/.github/workflows/integration-cassandra.yml +++ b/.github/workflows/integration-cassandra.yml @@ -52,3 +52,8 @@ jobs: run: cargo test -p extenddb-storage-cassandra --test metadata_integration -- --test-threads=1 - name: TTL integration tests run: cargo test -p extenddb-storage-cassandra --test ttl_integration -- --test-threads=1 + - name: Direct storage-trait integration tests + # Ported from the plug-in repo's tests/rust suite. Parallel-safe: + # each test provisions its own account and keyspace; the shared + # control-plane setting test restores what it mutates. + run: cargo test -p extenddb-storage-cassandra --test direct_integration diff --git a/crates/storage-cassandra/tests/direct/access_keys.rs b/crates/storage-cassandra/tests/direct/access_keys.rs new file mode 100644 index 00000000..91203475 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/access_keys.rs @@ -0,0 +1,264 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for access key operations. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_create_access_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + + // Need encryption key for access key creation + use extenddb_storage::bootstrapper::helpers::generate_encryption_key; + let enc_key = generate_encryption_key(); + let catalog_store = extenddb_storage_cassandra::CassandraCatalogStore::with_encryption_key( + engine.session_arc(), + config.keyspace_prefix.clone(), + config.datacenter.clone(), + config.replication_factor, + enc_key, + ); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + catalog_store + .create_access_key(&account_id, &user_name) + .await + .expect("Failed to create access key"); + + println!("✓ Access key created successfully"); + } + + #[tokio::test] + async fn test_store_and_fetch_session() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + + // Need encryption key for session storage + use extenddb_storage::bootstrapper::helpers::generate_encryption_key; + let enc_key = generate_encryption_key(); + let catalog_store = extenddb_storage_cassandra::CassandraCatalogStore::with_encryption_key( + engine.session_arc(), + config.keyspace_prefix.clone(), + config.datacenter.clone(), + config.replication_factor, + enc_key.clone(), + ); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let role_name = format!("testrole_{}", unique_test_id()); + let session_name = format!("session_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .unwrap(); + + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, &role_name, &trust_policy) + .await + .unwrap(); + + let session_token = format!("session_token_{}", unique_test_id()); + let access_key_id = format!("AKIATEST{}", unique_test_id()); + let secret_key = b"test_secret_key_12345678"; + let session_tags = Some(serde_json::json!([ + {"Key": "Department", "Value": "Engineering"}, + {"Key": "Project", "Value": "TestProject"} + ])); + let session_policy = Some(serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "*" + }] + })); + let expires_at = time::OffsetDateTime::now_utc() + time::Duration::hours(1); + + // Store session + catalog_store + .store_session( + &session_token, + &access_key_id, + secret_key, + &account_id, + &role_name, + &session_name, + &session_tags, + &session_policy, + expires_at, + ) + .await + .unwrap(); + println!("✓ Session stored"); + + // Fetch session data via AuthorizationStore + use extenddb_storage::authorization_store::AuthorizationStore; + let session_data = catalog_store + .fetch_session_data(&account_id, &role_name, &session_name) + .await + .unwrap(); + + assert!(session_data.is_some()); + let data = session_data.unwrap(); + + assert!(data.session_policy.is_some()); + assert_eq!(data.session_tags.len(), 2); + println!("✓ Session data fetched: {} tags", data.session_tags.len()); + + // Verify session tags content + assert!( + data.session_tags + .iter() + .any(|(k, v)| k == "Department" && v == "Engineering") + ); + assert!( + data.session_tags + .iter() + .any(|(k, v)| k == "Project" && v == "TestProject") + ); + println!("✓ Session tags verified"); + } + + #[tokio::test] + async fn test_fetch_caller_tags() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + let role_name = format!("testrole_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .unwrap(); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .unwrap(); + + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, &role_name, &trust_policy) + .await + .unwrap(); + + // Tag user + catalog_store + .tag_user( + &account_id, + &user_name, + &[ + ("Environment".to_string(), "Production".to_string()), + ("Owner".to_string(), "TeamA".to_string()), + ], + ) + .await + .unwrap(); + + // Tag role + catalog_store + .tag_role( + &account_id, + &role_name, + &[ + ("Department".to_string(), "Engineering".to_string()), + ("CostCenter".to_string(), "CC123".to_string()), + ], + ) + .await + .unwrap(); + + // Fetch caller tags for user + let user_resource = format!("user/{}", user_name); + let user_tags = catalog_store + .fetch_caller_tags(&account_id, &user_resource) + .await + .unwrap(); + assert_eq!(user_tags.len(), 2); + println!("✓ Fetched {} user caller tags", user_tags.len()); + + // Fetch caller tags for role + let role_resource = format!("role/{}", role_name); + let role_tags = catalog_store + .fetch_caller_tags(&account_id, &role_resource) + .await + .unwrap(); + assert_eq!(role_tags.len(), 2); + println!("✓ Fetched {} role caller tags", role_tags.len()); + + // Fetch caller tags for assumed-role + let assumed_role_resource = format!("assumed-role/{}/session-name", role_name); + let assumed_tags = catalog_store + .fetch_caller_tags(&account_id, &assumed_role_resource) + .await + .unwrap(); + assert_eq!(assumed_tags.len(), 2); + println!("✓ Fetched {} assumed-role caller tags", assumed_tags.len()); + + // Non-existent resource should return empty + let empty_tags = catalog_store + .fetch_caller_tags(&account_id, "invalid/format") + .await + .unwrap(); + assert!(empty_tags.is_empty()); + println!("✓ Invalid resource returns empty tags"); + + // Non-existent user should return empty + let empty_tags = catalog_store + .fetch_caller_tags(&account_id, "user/nonexistent") + .await + .unwrap(); + assert!(empty_tags.is_empty()); + println!("✓ Non-existent user returns empty tags"); + } +} diff --git a/crates/storage-cassandra/tests/direct/accounts.rs b/crates/storage-cassandra/tests/direct/accounts.rs new file mode 100644 index 00000000..fe3aef54 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/accounts.rs @@ -0,0 +1,144 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for Account management operations for `CassandraCatalogStore`. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_create_account() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + println!("✓ Account created successfully"); + + let result = catalog_store + .create_account(&account_id, &account_name) + .await; + match result { + Err(extenddb_storage::management_store::OpError::AlreadyExists(_)) => { + println!("✓ Duplicate account correctly rejected"); + } + other => panic!("Expected AlreadyExists, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_account_operations() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + // Happy case: create account + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + println!("✓ Account created successfully"); + + // Unhappy case: duplicate account (after keyspace ensured) + let result = catalog_store + .create_account(&account_id, &account_name) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::AlreadyExists(_)) => { + println!("✓ Duplicate account correctly rejected"); + } + other => panic!("Expected AlreadyExists, got: {:?}", other), + } + + // Happy case: list all accounts + let accounts = catalog_store + .list_all_accounts() + .await + .expect("Failed to list accounts"); + + assert!(accounts.iter().any(|(id, _)| id == &account_id)); + println!("✓ Account listed in list_all_accounts"); + + // Happy case: list accounts full + let accounts_full = catalog_store + .list_all_accounts_full() + .await + .expect("Failed to list accounts full"); + + assert!(accounts_full.iter().any(|(id, _, _)| id == &account_id)); + println!("✓ Account listed in list_all_accounts_full"); + + // Happy case: list accounts for specific account + let accounts_for = catalog_store + .list_accounts_for(&account_id) + .await + .expect("Failed to list accounts for"); + + assert_eq!(accounts_for.len(), 1); + assert_eq!(accounts_for[0].0, account_id); + println!("✓ list_accounts_for works"); + + // Happy case: get account detail + let detail = catalog_store + .get_account_detail(&account_id) + .await + .expect("Failed to get account detail") + .expect("Account detail should exist"); + + assert_eq!(detail.account_name, account_name); + assert_eq!(detail.users.len(), 0); + assert_eq!(detail.groups.len(), 0); + assert_eq!(detail.roles.len(), 0); + println!("✓ Account detail retrieved"); + + // Happy case: dashboard counts + let (account_count, _admin_count) = catalog_store + .dashboard_counts() + .await + .expect("Failed to get dashboard counts"); + + assert!(account_count > 0); + println!("✓ Dashboard counts: {} accounts", account_count); + + // Unhappy case: delete account with tables (would need to create a table first) + // Skip this as it requires full table engine setup + + // Happy case: delete account + catalog_store + .delete_account(&account_id) + .await + .expect("Failed to delete account"); + + println!("✓ Account deleted successfully"); + + // Unhappy case: delete non-existent account + let result = catalog_store.delete_account(&account_id).await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Delete non-existent account correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + } +} diff --git a/crates/storage-cassandra/tests/direct/admin_store.rs b/crates/storage-cassandra/tests/direct/admin_store.rs new file mode 100644 index 00000000..6a52f6d4 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/admin_store.rs @@ -0,0 +1,171 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for AdminStore trait implementation. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config}; + use extenddb_storage::management_store::AdminStore; + + #[tokio::test] + async fn test_admin_lifecycle() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let admin_name = format!("test-admin-{}", crate::helpers::unique_test_id()); + let password_hash = "test-hash-123"; + + // Create admin + catalog_store + .create_admin(&admin_name, password_hash) + .await + .expect("Failed to create admin"); + println!("✓ Admin created"); + + // Duplicate create should fail + let result = catalog_store.create_admin(&admin_name, password_hash).await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::AlreadyExists( + _ + )) + )); + println!("✓ Duplicate admin rejected"); + + // List admins (should have at least the test admin we just created) + let admins = catalog_store + .list_admins() + .await + .expect("Failed to list admins"); + assert!(!admins.is_empty()); + assert!(admins.iter().any(|a| a.admin_name == admin_name)); + println!("✓ List admins: {} admin(s)", admins.len()); + + // Delete admin + catalog_store + .delete_admin(&admin_name) + .await + .expect("Failed to delete admin"); + println!("✓ Admin deleted"); + + // Delete non-existent should fail + let result = catalog_store.delete_admin(&admin_name).await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::NotFound(_)) + )); + println!("✓ Delete non-existent admin rejected"); + + // List should not contain deleted admin + let admins = catalog_store + .list_admins() + .await + .expect("Failed to list admins"); + assert!(!admins.iter().any(|a| a.admin_name == admin_name)); + println!("✓ Admin list no longer contains deleted admin"); + } + + #[tokio::test] + async fn test_admin_password() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let admin_name = format!("password-test-admin-{}", crate::helpers::unique_test_id()); + let password = "test-password-123"; + + // Hash password using bcrypt + let password_hash = tokio::task::spawn_blocking({ + let password = password.to_string(); + move || bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap() + }) + .await + .unwrap(); + + // Create admin with password + catalog_store + .create_admin(&admin_name, &password_hash) + .await + .expect("Failed to create admin"); + println!("✓ Admin created with password"); + + // Verify correct password + let result = catalog_store + .verify_admin_password(&admin_name, password) + .await + .expect("Failed to verify password"); + assert_eq!(result, Some(true)); + println!("✓ Correct password verified"); + + // Verify wrong password + let result = catalog_store + .verify_admin_password(&admin_name, "wrong-password") + .await + .expect("Failed to verify password"); + assert_eq!(result, Some(false)); + println!("✓ Wrong password rejected"); + + // Verify non-existent admin + let result = catalog_store + .verify_admin_password("nonexistent", password) + .await + .expect("Failed to verify password"); + assert_eq!(result, None); + println!("✓ Non-existent admin returns None"); + + // Change password + let new_password = "new-password-456"; + let new_password_hash = tokio::task::spawn_blocking({ + let password = new_password.to_string(); + move || bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap() + }) + .await + .unwrap(); + + catalog_store + .change_admin_password(&admin_name, &new_password_hash) + .await + .expect("Failed to change password"); + println!("✓ Password changed"); + + // Old password should fail + let result = catalog_store + .verify_admin_password(&admin_name, password) + .await + .expect("Failed to verify password"); + assert_eq!(result, Some(false)); + println!("✓ Old password no longer works"); + + // New password should work + let result = catalog_store + .verify_admin_password(&admin_name, new_password) + .await + .expect("Failed to verify password"); + assert_eq!(result, Some(true)); + println!("✓ New password works"); + + // Change password for non-existent admin should fail + let result = catalog_store + .change_admin_password("nonexistent", &new_password_hash) + .await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::NotFound(_)) + )); + println!("✓ Change password for non-existent admin rejected"); + + // Cleanup + catalog_store + .delete_admin(&admin_name) + .await + .expect("Failed to delete admin"); + } +} diff --git a/crates/storage-cassandra/tests/direct/authorization_store.rs b/crates/storage-cassandra/tests/direct/authorization_store.rs new file mode 100644 index 00000000..165adb41 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/authorization_store.rs @@ -0,0 +1,353 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for AuthorizationStore trait implementation. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::authorization_store::AuthorizationStore; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_fetch_user_policies() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &format!("TestAccount_{}", unique_test_id())) + .await + .unwrap(); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .unwrap(); + + let policy_doc = serde_json::json!({"Version": "2012-10-17", "Statement": []}); + catalog_store + .put_policy(&account_id, "user", &user_name, "TestPolicy", &policy_doc) + .await + .unwrap(); + + let policies = catalog_store + .fetch_user_policies(&account_id, &user_name) + .await + .unwrap(); + + assert!(!policies.is_empty()); + println!("✓ Fetched {} user policies", policies.len()); + } + + #[tokio::test] + async fn test_fetch_user_group_policies() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &format!("TestAccount_{}", unique_test_id())) + .await + .unwrap(); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .unwrap(); + + let policies = catalog_store + .fetch_user_group_policies(&account_id, &user_name) + .await + .unwrap(); + + // Should be empty - user not in any groups + assert!(policies.is_empty()); + println!("✓ Fetched group policies (empty as expected)"); + } + + #[tokio::test] + async fn test_fetch_user_boundary() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &format!("TestAccount_{}", unique_test_id())) + .await + .unwrap(); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .unwrap(); + + let boundary = catalog_store + .fetch_user_boundary(&account_id, &user_name) + .await + .unwrap(); + + assert!(boundary.is_none()); + println!("✓ User has no permissions boundary"); + } + + #[tokio::test] + async fn test_fetch_role_policies() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let role_name = format!("testrole_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .unwrap(); + + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, &role_name, &trust_policy) + .await + .unwrap(); + + // Add a policy to the role + let policy_doc = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "*" + }] + }); + + catalog_store + .put_policy(&account_id, "role", &role_name, "TestPolicy", &policy_doc) + .await + .unwrap(); + + // Fetch role policies + let policies = catalog_store + .fetch_role_policies(&account_id, &role_name) + .await + .unwrap(); + + assert_eq!(policies.len(), 1); + println!("✓ Fetched {} role policy(ies)", policies.len()); + + // Verify policy content + let fetched_policy: serde_json::Value = serde_json::from_str(&policies[0]).unwrap(); + assert_eq!(fetched_policy, policy_doc); + println!("✓ Policy content matches"); + + // Non-existent role should return empty + let policies = catalog_store + .fetch_role_policies(&account_id, "nonexistent") + .await + .unwrap(); + assert!(policies.is_empty()); + println!("✓ Non-existent role returns empty policies"); + } + + #[tokio::test] + async fn test_fetch_role_boundary() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let role_name = format!("testrole_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .unwrap(); + + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, &role_name, &trust_policy) + .await + .unwrap(); + + // Initially no boundary + let boundary = catalog_store + .fetch_role_boundary(&account_id, &role_name) + .await + .unwrap(); + assert!(boundary.is_none()); + println!("✓ Role has no permissions boundary initially"); + + // Set a boundary + let boundary_doc = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:*", + "Resource": "*" + }] + }); + + catalog_store + .set_role_boundary(&account_id, &role_name, &boundary_doc) + .await + .unwrap(); + + // Fetch boundary + let boundary = catalog_store + .fetch_role_boundary(&account_id, &role_name) + .await + .unwrap(); + assert!(boundary.is_some()); + println!("✓ Role boundary set and fetched"); + + // Verify boundary content + let fetched_boundary: serde_json::Value = serde_json::from_str(&boundary.unwrap()).unwrap(); + assert_eq!(fetched_boundary, boundary_doc); + println!("✓ Boundary content matches"); + + // Non-existent role should return None + let boundary = catalog_store + .fetch_role_boundary(&account_id, "nonexistent") + .await + .unwrap(); + assert!(boundary.is_none()); + println!("✓ Non-existent role returns None boundary"); + } + + #[tokio::test] + async fn test_fetch_user_and_role_tags() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + let role_name = format!("testrole_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .unwrap(); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .unwrap(); + + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, &role_name, &trust_policy) + .await + .unwrap(); + + // Tag user + catalog_store + .tag_user( + &account_id, + &user_name, + &[ + ("Department".to_string(), "Engineering".to_string()), + ("Team".to_string(), "Platform".to_string()), + ], + ) + .await + .unwrap(); + + // Tag role + catalog_store + .tag_role( + &account_id, + &role_name, + &[ + ("Environment".to_string(), "Production".to_string()), + ("Owner".to_string(), "DevOps".to_string()), + ], + ) + .await + .unwrap(); + + // Fetch user tags + let user_tags = catalog_store + .fetch_user_tags(&account_id, &user_name) + .await + .unwrap(); + assert_eq!(user_tags.len(), 2); + println!("✓ Fetched {} user tag(s)", user_tags.len()); + + // Fetch role tags + let role_tags = catalog_store + .fetch_role_tags(&account_id, &role_name) + .await + .unwrap(); + assert_eq!(role_tags.len(), 2); + println!("✓ Fetched {} role tag(s)", role_tags.len()); + + // Non-existent resources should return empty + let user_tags = catalog_store + .fetch_user_tags(&account_id, "nonexistent") + .await + .unwrap(); + assert!(user_tags.is_empty()); + + let role_tags = catalog_store + .fetch_role_tags(&account_id, "nonexistent") + .await + .unwrap(); + assert!(role_tags.is_empty()); + println!("✓ Non-existent resources return empty tags"); + } +} diff --git a/crates/storage-cassandra/tests/direct/backup_engine.rs b/crates/storage-cassandra/tests/direct/backup_engine.rs new file mode 100644 index 00000000..44c9cc89 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/backup_engine.rs @@ -0,0 +1,383 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for Cassandra backup and restore operations. + +use std::collections::BTreeMap; +use std::time::Duration; + +use extenddb_core::types::{AttributeValue, DeleteTableInput, DescribeTableInput, TableStatus}; +use extenddb_storage::error::StorageError; +use extenddb_storage::{BackupEngine, DataEngine, TableEngine}; + +use crate::helpers::{TestAccount, TestTable, setup_engine, unique_test_account}; + +fn item(id: &str, sort: Option<&str>, value: &str) -> BTreeMap { + let mut item = BTreeMap::new(); + item.insert("id".to_owned(), AttributeValue::S(id.to_owned())); + if let Some(sort) = sort { + item.insert("sort".to_owned(), AttributeValue::S(sort.to_owned())); + } + item.insert("value".to_owned(), AttributeValue::S(value.to_owned())); + item +} + +fn key(id: &str, sort: Option<&str>) -> BTreeMap { + let mut key = BTreeMap::new(); + key.insert("id".to_owned(), AttributeValue::S(id.to_owned())); + if let Some(sort) = sort { + key.insert("sort".to_owned(), AttributeValue::S(sort.to_owned())); + } + key +} + +async fn activate_tables(engine: &extenddb_storage_cassandra::CassandraEngine) { + tokio::time::sleep(Duration::from_millis(350)).await; + engine + .process_control_plane_transitions() + .await + .expect("process table transitions"); +} + +#[tokio::test] +async fn test_backup_describe_list_delete_and_account_scope() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "BackupLifecycle", false).await; + activate_tables(&engine).await; + + for (id, value) in [("one", "first"), ("two", "second")] { + engine + .put_item( + &table.key_info, + item(id, None, value), + false, + None, + &Default::default(), + None, + ) + .await + .expect("seed item"); + } + + let details = engine + .create_backup( + &table.key_info.account_id, + &table.key_info.table_name, + "daily", + ) + .await + .expect("create backup"); + assert_eq!(details.backup_name, "daily"); + assert_eq!(details.backup_status, "AVAILABLE"); + assert_eq!(details.backup_type, "USER"); + assert!(details.backup_arn.contains(&format!( + ":{}:table/{}/backup/", + table.key_info.account_id, table.key_info.table_name + ))); + + let description = engine + .describe_backup(&table.key_info.account_id, &details.backup_arn) + .await + .expect("describe backup"); + assert_eq!( + description.source_table_details.table_id, + table.key_info.table_id + ); + assert_eq!(description.source_table_details.item_count, 2); + assert_eq!( + description.source_table_details.key_schema, + table.key_info.key_schema + ); + + let table_backups = engine + .list_backups(&table.key_info.account_id, Some(&table.key_info.table_name)) + .await + .expect("list table backups"); + assert_eq!(table_backups.len(), 1); + assert_eq!(table_backups[0].backup_arn, details.backup_arn); + assert_eq!( + engine + .list_backups(&table.key_info.account_id, None) + .await + .expect("list account backups") + .len(), + 1 + ); + + let foreign = unique_test_account(); + let foreign_error = engine + .describe_backup(&foreign, &details.backup_arn) + .await + .expect_err("another account must not resolve the backup"); + assert!( + matches!(foreign_error, StorageError::Validation(message) if message.contains("Backup not found")) + ); + + let deleted = engine + .delete_backup(&table.key_info.account_id, &details.backup_arn) + .await + .expect("delete backup"); + assert_eq!(deleted.backup_details.backup_status, "DELETED"); + assert!( + engine + .describe_backup(&table.key_info.account_id, &details.backup_arn) + .await + .is_err() + ); + assert!( + engine + .list_backups(&table.key_info.account_id, None) + .await + .expect("list after delete") + .is_empty() + ); +} + +#[tokio::test] +async fn test_restore_uses_immutable_snapshot_with_sort_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "BackupRestoreSource", true).await; + activate_tables(&engine).await; + + let original_one = item("partition", Some("one"), "before-backup"); + let original_two = item("partition", Some("two"), "preserved"); + for original in [&original_one, &original_two] { + engine + .put_item( + &table.key_info, + original.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("seed source item"); + } + + let backup = engine + .create_backup( + &table.key_info.account_id, + &table.key_info.table_name, + "immutable", + ) + .await + .expect("create backup"); + + engine + .put_item( + &table.key_info, + item("partition", Some("one"), "after-backup"), + false, + None, + &Default::default(), + None, + ) + .await + .expect("mutate source item"); + engine + .put_item( + &table.key_info, + item("partition", Some("three"), "new-after-backup"), + false, + None, + &Default::default(), + None, + ) + .await + .expect("add source item"); + + let target = "BackupRestoreTarget"; + let initial = engine + .restore_table_from_backup(&table.key_info.account_id, target, &backup.backup_arn) + .await + .expect("restore backup"); + assert!(matches!( + initial.table_status, + TableStatus::Creating | TableStatus::Active + )); + + let target_description = engine + .describe_table( + &table.key_info.account_id, + DescribeTableInput { + table_name: target.to_owned(), + }, + ) + .await + .expect("describe restored table"); + assert_eq!(target_description.table_status, TableStatus::Active); + + let restored_key_info = engine + .table_key_info(&table.key_info.account_id, target) + .await + .expect("restored table key info"); + let restored_one = engine + .get_item(&restored_key_info, &key("partition", Some("one"))) + .await + .expect("get first restored item") + .expect("first restored item exists"); + assert_eq!(restored_one.get("value"), original_one.get("value")); + let restored_two = engine + .get_item(&restored_key_info, &key("partition", Some("two"))) + .await + .expect("get second restored item") + .expect("second restored item exists"); + assert_eq!(restored_two.get("value"), original_two.get("value")); + assert!( + engine + .get_item(&restored_key_info, &key("partition", Some("three"))) + .await + .expect("get post-backup item") + .is_none() + ); + + engine + .delete_table( + &table.key_info.account_id, + DeleteTableInput { + table_name: target.to_owned(), + }, + ) + .await + .expect("delete restored table"); + engine + .delete_backup(&table.key_info.account_id, &backup.backup_arn) + .await + .expect("delete backup"); +} + +#[tokio::test] +async fn test_table_name_filter_spans_table_recreation() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account = TestAccount::new(&engine, "extenddb_test").await; + let table_name = "BackupRecreatedSource"; + let source = std::mem::ManuallyDrop::new( + TestTable::with_account(&engine, &account.account_id, table_name, false).await, + ); + activate_tables(&engine).await; + + engine + .put_item( + &source.key_info, + item("original", None, "from-old-schema"), + false, + None, + &Default::default(), + None, + ) + .await + .expect("seed original table"); + let backup = engine + .create_backup(&account.account_id, table_name, "before-recreate") + .await + .expect("backup original table"); + let original_table_id = source.key_info.table_id.clone(); + + engine + .delete_table( + &account.account_id, + DeleteTableInput { + table_name: table_name.to_owned(), + }, + ) + .await + .expect("delete original table"); + activate_tables(&engine).await; + + // Reuse the source name with a different schema. ListBackups is name-based, + // but restore must continue to use the immutable schema stored in the backup. + let replacement = TestTable::with_account(&engine, &account.account_id, table_name, true).await; + activate_tables(&engine).await; + assert_ne!(replacement.key_info.table_id, original_table_id); + assert_eq!(replacement.key_info.key_schema.len(), 2); + + let listed = engine + .list_backups(&account.account_id, Some(table_name)) + .await + .expect("list backups across table recreation"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].backup_arn, backup.backup_arn); + + let target = "BackupRecreatedRestore"; + engine + .restore_table_from_backup(&account.account_id, target, &backup.backup_arn) + .await + .expect("restore backup from original table incarnation"); + let restored_key_info = engine + .table_key_info(&account.account_id, target) + .await + .expect("restored table key info"); + assert_eq!(restored_key_info.key_schema.len(), 1); + let restored = engine + .get_item(&restored_key_info, &key("original", None)) + .await + .expect("read restored item") + .expect("restored item exists"); + assert_eq!( + restored.get("value"), + Some(&AttributeValue::S("from-old-schema".to_owned())) + ); + + engine + .delete_table( + &account.account_id, + DeleteTableInput { + table_name: target.to_owned(), + }, + ) + .await + .expect("delete restored table"); + engine + .delete_backup(&account.account_id, &backup.backup_arn) + .await + .expect("delete backup"); +} + +#[tokio::test] +async fn test_continuous_backup_state_and_pitr_restore_rejection() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ContinuousBackupState", false).await; + + let initial = engine + .describe_continuous_backups(&table.key_info.account_id, &table.key_info.table_name) + .await + .expect("describe default continuous backup state"); + assert_eq!(initial.continuous_backups_status, "ENABLED"); + let initial_pitr = initial.point_in_time_recovery_description.unwrap(); + assert_eq!(initial_pitr.point_in_time_recovery_status, "DISABLED"); + assert!(initial_pitr.earliest_restorable_date_time.is_none()); + + let enabled = engine + .update_continuous_backups(&table.key_info.account_id, &table.key_info.table_name, true) + .await + .expect("enable PITR state"); + let enabled_pitr = enabled.point_in_time_recovery_description.unwrap(); + assert_eq!(enabled_pitr.point_in_time_recovery_status, "ENABLED"); + assert!(enabled_pitr.earliest_restorable_date_time.is_some()); + assert!(enabled_pitr.latest_restorable_date_time.is_some()); + + let restore_error = engine + .restore_table_to_point_in_time( + &table.key_info.account_id, + &table.key_info.table_name, + "UnsupportedPitrTarget", + ) + .await + .expect_err("PITR restore must not fake a current-time snapshot"); + assert!( + matches!(restore_error, StorageError::Validation(message) if message.contains("not yet supported")) + ); +} diff --git a/crates/storage-cassandra/tests/direct/cassandra_engine.rs b/crates/storage-cassandra/tests/direct/cassandra_engine.rs new file mode 100644 index 00000000..682129b1 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/cassandra_engine.rs @@ -0,0 +1,303 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for CassandraEngine. +//! +//! Run with: cargo test -- --nocapture + +#[cfg(test)] +mod tests { + use crate::helpers::test_config; + use extenddb_storage_cassandra::engine::CassandraEngine; + + #[tokio::test] + async fn test_cassandra_connection() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + + let result = CassandraEngine::create_session(&config).await; + + match result { + Ok(_session) => { + println!("✓ Successfully connected to Cassandra"); + } + Err(e) => { + panic!("Failed to connect to Cassandra: {:?}", e); + } + } + } + + #[tokio::test] + async fn test_keyspace_operations() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + + let engine = CassandraEngine::new(&config, "us-east-1") + .await + .expect("Failed to create engine"); + + let test_keyspace = "test_connectivity_ks"; + + // Create keyspace + engine + .create_keyspace(test_keyspace) + .await + .expect("Failed to create keyspace"); + + println!("✓ Created keyspace: {}", test_keyspace); + + // Verify exists + let exists = engine + .keyspace_exists(test_keyspace) + .await + .expect("Failed to check keyspace existence"); + + assert!(exists, "Keyspace should exist after creation"); + println!("✓ Verified keyspace exists"); + + // Cleanup + engine + .drop_keyspace(test_keyspace) + .await + .expect("Failed to drop keyspace"); + + println!("✓ Dropped keyspace: {}", test_keyspace); + } + + /// Tests table_key_info() method. + #[tokio::test] + async fn test_table_key_info() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_core::types::{ + AttributeDefinition, BillingMode, CreateTableInput, DeleteTableInput, KeySchemaElement, + KeyType, ScalarAttributeType, + }; + use extenddb_storage::TableEngine; + + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1") + .await + .expect("Failed to create engine"); + + let account_id = crate::helpers::test_account_id(&engine) + .await + .expect("Failed to get test account"); + let table_name = "TestKeyInfoTable"; + + // Create a test table + let create_input = CreateTableInput { + vector_indexes: None, + table_throughput_mode: None, + table_name: table_name.to_string(), + key_schema: vec![ + KeySchemaElement { + attribute_name: "pk".to_string(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sk".to_string(), + key_type: KeyType::Range, + }, + ], + attribute_definitions: vec![ + AttributeDefinition { + attribute_name: "pk".to_string(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sk".to_string(), + attribute_type: ScalarAttributeType::N, + }, + ], + billing_mode: Some(BillingMode::PayPerRequest), + global_secondary_indexes: None, + local_secondary_indexes: None, + provisioned_throughput: None, + on_demand_throughput: None, + sse_specification: None, + stream_specification: None, + deletion_protection_enabled: None, + table_class: None, + tags: None, + }; + + // Create table + match engine.create_table(&account_id, create_input).await { + Ok(_) => println!("✓ Created test table"), + Err(extenddb_storage::error::StorageError::TableAlreadyExists(_)) => { + println!("✓ Table already exists, continuing") + } + Err(e) => panic!("Failed to create table: {:?}", e), + } + + // Manually update table status to ACTIVE for testing + // (bypass control plane delay mechanism) + let update_query = format!( + "UPDATE {}_catalog.tables SET table_status = 'ACTIVE' WHERE account_id = ? AND table_name = ?", + config.keyspace_prefix + ); + engine + .session_arc() + .query_with_values( + &update_query, + cdrs_tokio::query_values!(account_id.as_str(), table_name), + ) + .await + .expect("Failed to update table status"); + println!("✓ Table is ACTIVE"); + + // Test table_key_info + let key_info = engine + .table_key_info(&account_id, table_name) + .await + .expect("Failed to fetch table_key_info"); + + println!("✓ Fetched table_key_info"); + assert_eq!(key_info.table_name, table_name); + assert_eq!(key_info.account_id, account_id); + assert_eq!(key_info.key_schema.len(), 2); + assert_eq!(key_info.key_schema[0].attribute_name, "pk"); + assert_eq!(key_info.key_schema[0].key_type, KeyType::Hash); + assert_eq!(key_info.key_schema[1].attribute_name, "sk"); + assert_eq!(key_info.key_schema[1].key_type, KeyType::Range); + assert_eq!(key_info.attribute_definitions.len(), 2); + assert!(!key_info.table_id.is_empty()); + assert!(!key_info.has_lsi); + assert!(key_info.stream_specification.is_none()); + println!("✓ All assertions passed"); + + // Clean up + let _ = engine + .delete_table( + &account_id, + DeleteTableInput { + table_name: table_name.to_string(), + }, + ) + .await; + } + + /// Tests table_key_info() with non-existent table. + #[tokio::test] + async fn test_table_key_info_not_found() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::TableEngine; + + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1") + .await + .expect("Failed to create engine"); + + let account_id = crate::helpers::test_account_id(&engine) + .await + .expect("Failed to get test account"); + + let result = engine.table_key_info(&account_id, "NonExistentTable").await; + + match result { + Err(extenddb_storage::error::StorageError::TableNotFound(name)) => { + println!("✓ Correctly returned TableNotFound for: {}", name); + assert_eq!(name, "NonExistentTable"); + } + other => panic!("Expected TableNotFound, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_verify_admin_password() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::management_store::AdminStore; + + let config = test_config(); + let engine = crate::helpers::setup_engine().await; + + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + // Create an admin user for testing + let test_admin = format!("testadmin_{}", crate::helpers::unique_test_id()); + let test_password = "test_password_123"; + + // Hash the password like bootstrapper does + let password_hash: String = tokio::task::spawn_blocking({ + let password = test_password.to_string(); + move || bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap() + }) + .await + .unwrap(); + + catalog_store + .create_admin(&test_admin, &password_hash) + .await + .expect("Failed to create test admin user"); + + // Test with correct password + let result = catalog_store + .verify_admin_password(&test_admin, test_password) + .await; + + match result { + Ok(Some(true)) => { + println!("✓ Admin password verification succeeded with correct password"); + } + Ok(Some(false)) => { + panic!("Should verify with correct password!"); + } + Ok(None) => { + panic!("Admin user '{}' should exist", test_admin); + } + Err(e) => { + panic!("verify_admin_password failed: {:?}", e); + } + } + + // Test with wrong password + let result = catalog_store + .verify_admin_password(&test_admin, "wrong_password") + .await; + + match result { + Ok(Some(false)) => { + println!( + "✓ Admin password verification correctly returned false for wrong password" + ); + } + Ok(Some(true)) => { + panic!("Should not verify with wrong password!"); + } + Ok(None) => { + panic!("Admin user '{}' should exist", test_admin); + } + Err(e) => { + panic!("verify_admin_password failed: {:?}", e); + } + } + + // Test with non-existent user + let result = catalog_store + .verify_admin_password("nonexistent", "password") + .await; + match result { + Ok(None) => { + println!( + "✓ Admin password verification correctly returned None for non-existent user" + ); + } + Ok(Some(_)) => { + panic!("Non-existent user should return None"); + } + Err(e) => { + panic!("verify_admin_password failed: {:?}", e); + } + } + } +} diff --git a/crates/storage-cassandra/tests/direct/delete_item.rs b/crates/storage-cassandra/tests/direct/delete_item.rs new file mode 100644 index 00000000..7f996a0c --- /dev/null +++ b/crates/storage-cassandra/tests/direct/delete_item.rs @@ -0,0 +1,747 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for delete_item operation. + +use extenddb_storage::DataEngine; +use std::collections::BTreeMap; + +use crate::helpers::{TestTable, setup_engine}; + +#[tokio::test] +async fn test_delete_item_pk_only_exists() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "test_delete_pk", false).await; + + // Put an item + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("test-id".to_string()), + ); + item.insert( + "data".to_string(), + extenddb_core::types::AttributeValue::S("test data".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item failed"); + + // Delete the item (return_old=true) + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("test-id".to_string()), + ); + + let result = engine + .delete_item(&table.key_info, &key, true, None, &Default::default(), None) + .await + .expect("delete_item failed"); + + // Should return the old item + assert!(result.is_some()); + let old_item = result.unwrap(); + assert_eq!(old_item.get("id"), item.get("id")); + assert_eq!(old_item.get("data"), item.get("data")); + + // Verify item is deleted + let get_result = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item failed"); + assert!(get_result.is_none()); +} + +#[tokio::test] +async fn test_delete_item_pk_only_not_exists() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "test_delete_pk_notexists", false).await; + + // Try to delete non-existent item + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("nonexistent".to_string()), + ); + + let result = engine + .delete_item(&table.key_info, &key, true, None, &Default::default(), None) + .await + .expect("delete_item failed"); + + // Should return None when item doesn't exist + assert!(result.is_none()); +} + +#[tokio::test] +async fn test_delete_item_with_sk_exists() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "test_delete_sk", true).await; + + // Put an item + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("user-123".to_string()), + ); + item.insert( + "sort".to_string(), + extenddb_core::types::AttributeValue::S("order-456".to_string()), + ); + item.insert( + "amount".to_string(), + extenddb_core::types::AttributeValue::N("99.99".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item failed"); + + // Delete the item + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("user-123".to_string()), + ); + key.insert( + "sort".to_string(), + extenddb_core::types::AttributeValue::S("order-456".to_string()), + ); + + let result = engine + .delete_item(&table.key_info, &key, true, None, &Default::default(), None) + .await + .expect("delete_item failed"); + + assert!(result.is_some()); + let old_item = result.unwrap(); + assert_eq!(old_item.get("id"), item.get("id")); + assert_eq!(old_item.get("amount"), item.get("amount")); +} + +#[tokio::test] +async fn test_delete_item_with_sk_not_exists() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "test_delete_sk_notexists", true).await; + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("nonexistent".to_string()), + ); + key.insert( + "sort".to_string(), + extenddb_core::types::AttributeValue::S("missing".to_string()), + ); + + let result = engine + .delete_item(&table.key_info, &key, true, None, &Default::default(), None) + .await + .expect("delete_item failed"); + + assert!(result.is_none()); +} + +#[tokio::test] +async fn test_delete_item_return_old_false() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "test_delete_noreturn", false).await; + + // Put an item + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("test-id".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item failed"); + + // Delete without returning old value + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("test-id".to_string()), + ); + + let result = engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await + .expect("delete_item failed"); + + assert!(result.is_none()); + + // Verify deletion + let get_result = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item failed"); + assert!(get_result.is_none()); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Transaction protection tests (Phase 3, T3.1) +// ═══════════════════════════════════════════════════════════════════════════════ + +use crate::helpers::put_item_then_lock; +use extenddb_core::types::AttributeValue; + +#[tokio::test] +async fn test_delete_item_rejects_when_prepared_txn_id_set_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtDelPk", false).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("del-locked".to_string()), + ); + item.insert( + "data".to_string(), + AttributeValue::S("precious".to_string()), + ); + + put_item_then_lock(&engine, &table, &item).await; + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("del-locked".to_string()), + ); + + let result = engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_delete_item_rejects_when_prepared_txn_id_set_with_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtDelSk", true).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("dpk1".to_string())); + item.insert("sort".to_string(), AttributeValue::S("dsk1".to_string())); + item.insert( + "data".to_string(), + AttributeValue::S("important".to_string()), + ); + + put_item_then_lock(&engine, &table, &item).await; + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("dpk1".to_string())); + key.insert("sort".to_string(), AttributeValue::S("dsk1".to_string())); + + let result = engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// partition_max_delete_timestamp tests (Phase 3, T3.2) +// ═══════════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn test_delete_item_sets_partition_max_delete_timestamp_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnDelTsPk", false).await; + + // Put an item + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("ts-item".to_string())); + item.insert("data".to_string(), AttributeValue::S("value".to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + + // Delete it + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("ts-item".to_string())); + + engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await + .expect("delete_item should succeed"); + + // For PK-only tables the timestamp is a regular column and is deleted + // with the row — the persistence protection only exists for sort-key + // tables where it is STATIC. What this shape CAN assert: the delete + // actually removed the item. + assert!( + engine + .get_item(&table.key_info, &key) + .await + .expect("get after delete") + .is_none(), + "item still present after delete" + ); +} + +#[tokio::test] +async fn test_delete_item_sets_partition_max_delete_timestamp_with_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + use cdrs_tokio::types::IntoRustByName; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnDelTsSk", true).await; + + // Put two items in the same partition + let mut item1 = BTreeMap::new(); + item1.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item1.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + item1.insert("data".to_string(), AttributeValue::S("val1".to_string())); + + let mut item2 = BTreeMap::new(); + item2.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item2.insert("sort".to_string(), AttributeValue::S("sk2".to_string())); + item2.insert("data".to_string(), AttributeValue::S("val2".to_string())); + + engine + .put_item( + &table.key_info, + item1, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item 1 should succeed"); + engine + .put_item( + &table.key_info, + item2, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item 2 should succeed"); + + // Delete one item - this should set partition_max_delete_timestamp + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + key.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + + engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await + .expect("delete_item should succeed"); + + // Read partition_max_delete_timestamp from the remaining row (STATIC column + // is shared across all rows in the partition) + let account_keyspace = format!("extenddb_ttl_test_account_{}", table.key_info.account_id); + let data_table = format!("items_{}", table.key_info.table_id.replace("-", "_")); + + let query = format!( + "SELECT partition_max_delete_timestamp FROM {}.{} WHERE pk = ? LIMIT 1", + account_keyspace, data_table + ); + let result = engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!("pk1")) + .await + .expect("SELECT should succeed"); + + let body = result.response_body().expect("response_body"); + let rows = body.into_rows().expect("should have rows"); + let row = rows + .into_iter() + .next() + .expect("should have at least one row"); + + let max_ts: Option = row + .get_by_name("partition_max_delete_timestamp") + .ok() + .flatten(); + + assert!( + max_ts.is_some(), + "partition_max_delete_timestamp should be set after delete" + ); + assert!( + max_ts.unwrap() > 0, + "partition_max_delete_timestamp should be a positive timestamp" + ); +} + +#[tokio::test] +async fn test_delete_item_partition_max_timestamp_increases_on_subsequent_deletes() { + if crate::helpers::skip_without_cassandra() { + return; + } + use cdrs_tokio::types::IntoRustByName; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnDelTsInc", true).await; + + // Put two items in the same partition + let mut item1 = BTreeMap::new(); + item1.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item1.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + item1.insert("data".to_string(), AttributeValue::S("val1".to_string())); + + let mut item2 = BTreeMap::new(); + item2.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item2.insert("sort".to_string(), AttributeValue::S("sk2".to_string())); + item2.insert("data".to_string(), AttributeValue::S("val2".to_string())); + + engine + .put_item( + &table.key_info, + item1, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item 1"); + engine + .put_item( + &table.key_info, + item2, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item 2"); + + // Delete first item + let mut key1 = BTreeMap::new(); + key1.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + key1.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + + engine + .delete_item( + &table.key_info, + &key1, + false, + None, + &Default::default(), + None, + ) + .await + .expect("delete_item 1"); + + // Read first timestamp + let account_keyspace = format!("extenddb_ttl_test_account_{}", table.key_info.account_id); + let data_table = format!("items_{}", table.key_info.table_id.replace("-", "_")); + + let query = format!( + "SELECT partition_max_delete_timestamp FROM {}.{} WHERE pk = ? LIMIT 1", + account_keyspace, data_table + ); + let result = engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!("pk1")) + .await + .unwrap(); + let body = result.response_body().unwrap(); + let rows = body.into_rows().unwrap(); + let row = rows.into_iter().next().unwrap(); + let ts1: i64 = row + .get_by_name("partition_max_delete_timestamp") + .ok() + .flatten() + .expect("ts1 should be set"); + + // Small delay to ensure different timestamp + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + // Re-create and delete second item + let mut item2_again = BTreeMap::new(); + item2_again.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item2_again.insert("sort".to_string(), AttributeValue::S("sk2".to_string())); + item2_again.insert("data".to_string(), AttributeValue::S("val2b".to_string())); + + // sk2 still exists from initial put, so just delete it + let mut key2 = BTreeMap::new(); + key2.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + key2.insert("sort".to_string(), AttributeValue::S("sk2".to_string())); + + engine + .delete_item( + &table.key_info, + &key2, + false, + None, + &Default::default(), + None, + ) + .await + .expect("delete_item 2"); + + // Re-insert an item so we can read the STATIC column + let mut item3 = BTreeMap::new(); + item3.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item3.insert("sort".to_string(), AttributeValue::S("sk3".to_string())); + item3.insert("data".to_string(), AttributeValue::S("val3".to_string())); + + engine + .put_item( + &table.key_info, + item3, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item 3"); + + let result2 = engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!("pk1")) + .await + .unwrap(); + let body2 = result2.response_body().unwrap(); + let rows2 = body2.into_rows().unwrap(); + let row2 = rows2.into_iter().next().unwrap(); + let ts2: i64 = row2 + .get_by_name("partition_max_delete_timestamp") + .ok() + .flatten() + .expect("ts2 should be set"); + + assert!( + ts2 >= ts1, + "partition_max_delete_timestamp should not decrease: ts1={}, ts2={}", + ts1, + ts2 + ); +} + +#[tokio::test] +async fn test_transaction_put_rejected_by_partition_max_delete_timestamp() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_core::expression::ExpressionMaps; + use extenddb_core::types::ReturnValuesOnConditionCheckFailure; + use extenddb_storage::TransactWriteOp; + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnDelTsBlock", true).await; + + // Put an item in the partition so we have a row to hold the STATIC column + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item.insert("sort".to_string(), AttributeValue::S("sk-keep".to_string())); + item.insert("data".to_string(), AttributeValue::S("anchor".to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item anchor should succeed"); + + // Manually set partition_max_delete_timestamp to a far-future value. + // This simulates a delete that happened "after" any transaction that's + // currently in-flight would have started. + let account_keyspace = format!("extenddb_ttl_test_account_{}", table.key_info.account_id); + let data_table = format!("items_{}", table.key_info.table_id.replace("-", "_")); + + let far_future_ts: i64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64 + + 600_000; // 10 minutes in the future + + let update_query = format!( + "UPDATE {}.{} SET partition_max_delete_timestamp = ? WHERE pk = ?", + account_keyspace, data_table + ); + engine + .session_arc() + .query_with_values( + &update_query, + cdrs_tokio::query_values!(far_future_ts, "pk1"), + ) + .await + .expect("Setting partition_max_delete_timestamp should succeed"); + + // Now try a transaction that puts a NEW item in the same partition. + // The transaction's timestamp will be less than partition_max_delete_timestamp, + // so it should be rejected. + let mut new_item = BTreeMap::new(); + new_item.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + new_item.insert("sort".to_string(), AttributeValue::S("sk-new".to_string())); + new_item.insert("data".to_string(), AttributeValue::S("stale".to_string())); + + let maps = ExpressionMaps::default(); + let ops = vec![TransactWriteOp::Put { + key_info: &table.key_info, + item: &new_item, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + + let result = engine.transact_write_items(&ops, None).await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + // The cancellation reason should indicate the item was rejected + assert!( + !reasons.is_empty(), + "Should have at least one cancellation reason" + ); + // The specific message is "Item was deleted at a later timestamp" + assert!( + reasons[0] + .message + .as_deref() + .unwrap_or("") + .contains("deleted"), + "Expected deletion-related rejection, got: {:?}", + reasons[0] + ); + } + Ok(()) => { + panic!("Transaction should have been rejected due to partition_max_delete_timestamp") + } + Err(other) => panic!("Expected TransactionCanceled, got: {:?}", other), + } + + // Verify the new item was NOT created + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + key.insert("sort".to_string(), AttributeValue::S("sk-new".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item should succeed"); + assert!( + retrieved.is_none(), + "Item should not exist - transaction was rejected" + ); +} diff --git a/crates/storage-cassandra/tests/direct/groups.rs b/crates/storage-cassandra/tests/direct/groups.rs new file mode 100644 index 00000000..53868936 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/groups.rs @@ -0,0 +1,182 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for Group management operations for `CassandraCatalogStore`. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_group_lifecycle() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let group_name = format!("testgroup_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Happy case: create group + catalog_store + .create_group(&account_id, &group_name) + .await + .expect("Failed to create group"); + + println!("✓ Group created successfully"); + + // Unhappy case: duplicate group + let result = catalog_store.create_group(&account_id, &group_name).await; + + match result { + Err(extenddb_storage::management_store::OpError::AlreadyExists(_)) => { + println!("✓ Duplicate group correctly rejected"); + } + other => panic!("Expected AlreadyExists, got: {:?}", other), + } + + // Happy case: list groups + let groups = catalog_store + .list_groups(&account_id) + .await + .expect("Failed to list groups"); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].1, group_name); + println!("✓ Group listed successfully"); + + // Happy case: delete group + catalog_store + .delete_group(&account_id, &group_name) + .await + .expect("Failed to delete group"); + + println!("✓ Group deleted successfully"); + + // Unhappy case: delete non-existent group + let result = catalog_store.delete_group(&account_id, &group_name).await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Delete non-existent group correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_group_members() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let group_name = format!("testgroup_{}", unique_test_id()); + let user1 = format!("user1_{}", unique_test_id()); + let user2 = format!("user2_{}", unique_test_id()); + + // Setup + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_group(&account_id, &group_name) + .await + .expect("Failed to create group"); + + catalog_store + .create_user(&account_id, &user1, None) + .await + .expect("Failed to create user1"); + + catalog_store + .create_user(&account_id, &user2, None) + .await + .expect("Failed to create user2"); + + // Happy case: add member + catalog_store + .add_group_member(&account_id, &group_name, &user1) + .await + .expect("Failed to add member"); + + println!("✓ Member added successfully"); + + // Unhappy case: add duplicate member + let result = catalog_store + .add_group_member(&account_id, &group_name, &user1) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::AlreadyExists(_)) => { + println!("✓ Duplicate member correctly rejected"); + } + other => panic!("Expected AlreadyExists, got: {:?}", other), + } + + // Add second member + catalog_store + .add_group_member(&account_id, &group_name, &user2) + .await + .expect("Failed to add second member"); + + // Happy case: get group detail + let detail = catalog_store + .get_group_detail(&account_id, &group_name) + .await + .expect("Failed to get group detail") + .expect("Group detail should exist"); + + assert_eq!(detail.members.len(), 2); + assert!(detail.members.contains(&user1)); + assert!(detail.members.contains(&user2)); + assert_eq!(detail.all_users.len(), 2); + println!("✓ Group detail retrieved successfully"); + + // Happy case: remove member + catalog_store + .remove_group_member(&account_id, &group_name, &user1) + .await + .expect("Failed to remove member"); + + println!("✓ Member removed successfully"); + + // Unhappy case: remove non-existent membership + let result = catalog_store + .remove_group_member(&account_id, &group_name, &user1) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Remove non-existent membership correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + + // Verify only one member remains + let detail = catalog_store + .get_group_detail(&account_id, &group_name) + .await + .expect("Failed to get group detail") + .expect("Group detail should exist"); + + assert_eq!(detail.members.len(), 1); + assert!(detail.members.contains(&user2)); + println!("✓ Membership update verified"); + } +} diff --git a/crates/storage-cassandra/tests/direct/index.rs b/crates/storage-cassandra/tests/direct/index.rs new file mode 100644 index 00000000..ca2e0b53 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/index.rs @@ -0,0 +1,848 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for GSI/LSI index operations. + +use extenddb_core::types::{ + AttributeDefinition, AttributeValue, Item, KeySchemaElement, KeyType, Projection, + ProjectionType, ScalarAttributeType, +}; +use extenddb_storage_cassandra::CassandraEngine; + +use crate::helpers::{ensure_test_account, test_config, unique_test_account, unique_test_id}; + +#[tokio::test] +async fn test_create_and_drop_index_table() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let account_id = unique_test_account(); + let index_id = unique_test_id(); + + ensure_test_account(&engine, &account_id).await.unwrap(); + + let account_keyspace = engine.account_keyspace(&account_id); + + // Base table key schema: pk (HASH), sk (RANGE) + let base_key_schema = vec![ + KeySchemaElement { + attribute_name: "pk".to_owned(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sk".to_owned(), + key_type: KeyType::Range, + }, + ]; + + let base_attr_defs = vec![ + AttributeDefinition { + attribute_name: "pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sk".to_owned(), + attribute_type: ScalarAttributeType::N, + }, + ]; + + // Index key schema: gsi_pk (HASH), gsi_sk (RANGE) + let index_key_schema = vec![ + KeySchemaElement { + attribute_name: "gsi_pk".to_owned(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "gsi_sk".to_owned(), + key_type: KeyType::Range, + }, + ]; + + let index_attr_defs = vec![ + AttributeDefinition { + attribute_name: "gsi_pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "gsi_sk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + ]; + + // Create index table + engine + .create_index_data_table( + &account_keyspace, + &index_id, + &index_key_schema, + &index_attr_defs, + &base_key_schema, + &base_attr_defs, + ) + .await + .unwrap(); + + // Verify table exists by querying system schema + let table_name = format!("index_{}", index_id.replace("-", "_")); + let query = + "SELECT table_name FROM system_schema.tables WHERE keyspace_name = ? AND table_name = ?" + .to_string(); + let result = engine + .session() + .query_with_values( + &query, + cdrs_tokio::query_values!(account_keyspace.as_str(), table_name.as_str()), + ) + .await + .unwrap(); + + let body = result.response_body().unwrap(); + let rows = body.into_rows().unwrap(); + assert_eq!(rows.len(), 1); + + // Drop index table + engine + .drop_index_data_table(&account_keyspace, &index_id) + .await + .unwrap(); + + // Verify table is gone + let result = engine + .session() + .query_with_values( + &query, + cdrs_tokio::query_values!(account_keyspace.as_str(), table_name.as_str()), + ) + .await + .unwrap(); + + let body = result.response_body().unwrap(); + let rows = body.into_rows().unwrap_or_default(); + assert_eq!(rows.len(), 0); +} + +#[tokio::test] +async fn test_fetch_indexes_for_table() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let account_id = unique_test_account(); + let table_id = unique_test_id(); + + ensure_test_account(&engine, &account_id).await.unwrap(); + + let catalog_keyspace = engine.catalog_keyspace(); + + // Insert test index metadata into catalog + let index_id = unique_test_id(); + let key_schema = serde_json::json!([ + {"AttributeName": "gsi_pk", "KeyType": "HASH"}, + {"AttributeName": "gsi_sk", "KeyType": "RANGE"} + ]) + .to_string(); + + let projection = serde_json::json!({ + "ProjectionType": "ALL" + }) + .to_string(); + + let insert_query = format!( + "INSERT INTO {}.indexes (table_id, index_name, index_id, index_type, key_schema, projection, index_status, propagation_delay_ms) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + catalog_keyspace + ); + + engine + .session() + .query_with_values( + &insert_query, + cdrs_tokio::query_values!( + table_id.as_str(), + "test_gsi", + index_id.as_str(), + "GSI", + key_schema.as_str(), + projection.as_str(), + "ACTIVE", + 1000 + ), + ) + .await + .unwrap(); + + // Fetch indexes + let indexes = extenddb_storage_cassandra::data::index::fetch_indexes_for_table( + &table_id, + &engine.session_arc(), + &catalog_keyspace, + ) + .await + .unwrap(); + + assert_eq!(indexes.len(), 1); + assert_eq!(indexes[0].index_name, "test_gsi"); + assert_eq!(indexes[0].index_id, index_id); + assert_eq!(indexes[0].index_type, "GSI"); + assert_eq!(indexes[0].key_schema.len(), 2); + assert_eq!(indexes[0].propagation_delay_ms, Some(1000)); + assert_eq!(indexes[0].projection.projection_type, ProjectionType::All); + + // Cleanup + let delete_query = format!( + "DELETE FROM {}.indexes WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &delete_query, + cdrs_tokio::query_values!(table_id.as_str(), "test_gsi"), + ) + .await + .ok(); +} + +#[tokio::test] +async fn test_index_table_primary_key_structure() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let account_id = unique_test_account(); + let index_id = unique_test_id(); + + ensure_test_account(&engine, &account_id).await.unwrap(); + + let account_keyspace = engine.account_keyspace(&account_id); + + // Base table: pk (S), sk (N) + let base_key_schema = vec![ + KeySchemaElement { + attribute_name: "pk".to_owned(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sk".to_owned(), + key_type: KeyType::Range, + }, + ]; + + let base_attr_defs = vec![ + AttributeDefinition { + attribute_name: "pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sk".to_owned(), + attribute_type: ScalarAttributeType::N, + }, + ]; + + // GSI: gsi_pk (S), gsi_sk (S) + let index_key_schema = vec![ + KeySchemaElement { + attribute_name: "gsi_pk".to_owned(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "gsi_sk".to_owned(), + key_type: KeyType::Range, + }, + ]; + + let index_attr_defs = vec![ + AttributeDefinition { + attribute_name: "gsi_pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "gsi_sk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + ]; + + engine + .create_index_data_table( + &account_keyspace, + &index_id, + &index_key_schema, + &index_attr_defs, + &base_key_schema, + &base_attr_defs, + ) + .await + .unwrap(); + + let table_name = format!("index_{}", index_id.replace("-", "_")); + + // Query system schema to verify PRIMARY KEY structure + let query = "SELECT column_name, kind, position FROM system_schema.columns \ + WHERE keyspace_name = ? AND table_name = ?" + .to_string(); + + let result = engine + .session() + .query_with_values( + &query, + cdrs_tokio::query_values!(account_keyspace.as_str(), table_name.as_str()), + ) + .await + .unwrap(); + + let body = result.response_body().unwrap(); + let mut rows = body.into_rows().unwrap(); + + // Sort by position in application (can't ORDER BY non-clustering column) + rows.sort_by_key(|row| { + use cdrs_tokio::types::IntoRustByName; + let pos: i32 = row.get_r_by_name("position").unwrap_or(0); + pos + }); + + // Verify PRIMARY KEY order: (pk) as partition key, then sk_s, base_pk, base_sk_n as clustering + let mut partition_keys = Vec::new(); + let mut clustering_keys = Vec::new(); + + for row in rows { + use cdrs_tokio::types::IntoRustByName; + let col_name: String = row.get_r_by_name("column_name").unwrap(); + let kind: String = row.get_r_by_name("kind").unwrap(); + + match kind.as_str() { + "partition_key" => partition_keys.push(col_name), + "clustering" => clustering_keys.push(col_name), + _ => {} + } + } + + // Verify structure + assert_eq!(partition_keys, vec!["pk"]); + assert_eq!( + clustering_keys, + vec!["sk_s", "base_pk", "base_sk_n"], + "Clustering keys should be: index SK (sk_s), then base keys (base_pk, base_sk_n)" + ); + + // Cleanup + engine + .drop_index_data_table(&account_keyspace, &index_id) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_sync_indexes_insert_and_delete() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let account_id = unique_test_account(); + let _table_id = unique_test_id(); + let index_id = unique_test_id(); + + ensure_test_account(&engine, &account_id).await.unwrap(); + + let account_keyspace = engine.account_keyspace(&account_id); + + // Base table: pk (S), sk (N) + let base_key_schema = vec![ + KeySchemaElement { + attribute_name: "pk".to_owned(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sk".to_owned(), + key_type: KeyType::Range, + }, + ]; + + let base_attr_defs = vec![ + AttributeDefinition { + attribute_name: "pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sk".to_owned(), + attribute_type: ScalarAttributeType::N, + }, + AttributeDefinition { + attribute_name: "gsi_pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + ]; + + // GSI: gsi_pk (S) with sync delay + let index_key_schema = vec![KeySchemaElement { + attribute_name: "gsi_pk".to_owned(), + key_type: KeyType::Hash, + }]; + + let index_attr_defs = vec![AttributeDefinition { + attribute_name: "gsi_pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }]; + + // Create index table + engine + .create_index_data_table( + &account_keyspace, + &index_id, + &index_key_schema, + &index_attr_defs, + &base_key_schema, + &base_attr_defs, + ) + .await + .unwrap(); + + // Create an index metadata entry + let indexes = vec![extenddb_storage_cassandra::data::index::IndexMeta { + index_name: "test_gsi".to_owned(), + index_id: index_id.clone(), + index_type: "GSI".to_owned(), + key_schema: index_key_schema.clone(), + projection: Projection { + projection_type: ProjectionType::All, + non_key_attributes: None, + }, + propagation_delay_ms: Some(0), // Sync + }]; + + // Create a test item + let mut item = Item::new(); + item.insert("pk".to_owned(), AttributeValue::S("test_pk".to_owned())); + item.insert("sk".to_owned(), AttributeValue::N("123".to_owned())); + item.insert( + "gsi_pk".to_owned(), + AttributeValue::S("gsi_value".to_owned()), + ); + item.insert("data".to_owned(), AttributeValue::S("some_data".to_owned())); + + // Test sync_indexes for INSERT + let mut batch = cdrs_tokio::query::BatchQueryBuilder::new(); + extenddb_storage_cassandra::data::index::sync_indexes( + &mut batch, + &account_keyspace, + &base_key_schema, + &base_attr_defs, + &indexes, + None, + Some(&item), + 1000, + ) + .unwrap(); + + let built = batch.build().unwrap(); + assert_eq!( + built.request.queries.len(), + 1, + "Expected 1 INSERT statement" + ); + + // Execute the batch + engine.session().batch(built).await.unwrap(); + + // Verify the row was inserted + let idx_table = format!("index_{}", index_id.replace("-", "_")); + let query = format!( + "SELECT item_data FROM {}.{} WHERE pk = 'gsi_value' AND base_pk = 'test_pk' AND base_sk_n = 123", + account_keyspace, idx_table + ); + println!("SELECT query: {}", query); + let result = engine.session().query(&query).await.unwrap(); + let body = result.response_body().unwrap(); + let rows = body.into_rows().unwrap(); + assert_eq!(rows.len(), 1); + + // Test sync_indexes for DELETE + let mut batch = cdrs_tokio::query::BatchQueryBuilder::new(); + extenddb_storage_cassandra::data::index::sync_indexes( + &mut batch, + &account_keyspace, + &base_key_schema, + &base_attr_defs, + &indexes, + Some(&item), + None, + 1000, + ) + .unwrap(); + + let built = batch.build().unwrap(); + assert_eq!( + built.request.queries.len(), + 1, + "Expected 1 DELETE statement" + ); + + // Execute the batch + engine.session().batch(built).await.unwrap(); + + // Verify the row was deleted + let result = engine.session().query(&query).await.unwrap(); + let body = result.response_body().unwrap(); + let rows = body.into_rows().unwrap_or_default(); + assert_eq!(rows.len(), 0); + + // Cleanup + engine + .drop_index_data_table(&account_keyspace, &index_id) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_sync_indexes_skips_async_gsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let account_id = unique_test_account(); + let _table_id = unique_test_id(); + + ensure_test_account(&engine, &account_id).await.unwrap(); + + let account_keyspace = engine.account_keyspace(&account_id); + + let base_key_schema = vec![KeySchemaElement { + attribute_name: "pk".to_owned(), + key_type: KeyType::Hash, + }]; + + let base_attr_defs = vec![ + AttributeDefinition { + attribute_name: "pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "gsi_pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + ]; + + // Async GSI with delay > 0 + let indexes = vec![extenddb_storage_cassandra::data::index::IndexMeta { + index_name: "async_gsi".to_owned(), + index_id: unique_test_id(), + index_type: "GSI".to_owned(), + key_schema: vec![KeySchemaElement { + attribute_name: "gsi_pk".to_owned(), + key_type: KeyType::Hash, + }], + projection: Projection { + projection_type: ProjectionType::KeysOnly, + non_key_attributes: None, + }, + propagation_delay_ms: Some(1000), // Async + }]; + + let mut item = Item::new(); + item.insert("pk".to_owned(), AttributeValue::S("test".to_owned())); + item.insert("gsi_pk".to_owned(), AttributeValue::S("value".to_owned())); + + // sync_indexes should NOT add any statements for async GSI + let mut batch = cdrs_tokio::query::BatchQueryBuilder::new(); + extenddb_storage_cassandra::data::index::sync_indexes( + &mut batch, + &account_keyspace, + &base_key_schema, + &base_attr_defs, + &indexes, + None, + Some(&item), + 500, // System default < GSI delay + ) + .unwrap(); + + let built = batch.build().unwrap(); + assert_eq!( + built.request.queries.len(), + 0, + "Async GSI should not generate sync statements" + ); +} + +// ── Async GSI queue integration tests ──────────────────────────────────────── + +use extenddb_core::expression::ExpressionMaps; +use extenddb_storage::DataEngine as _; +use std::sync::Arc; + +/// Set the propagation delay for a GSI in the catalog. +async fn set_gsi_delay(engine: &CassandraEngine, table_id: &str, gsi_name: &str, delay_ms: i32) { + let catalog_keyspace = engine.catalog_keyspace(); + let cql = format!( + "UPDATE {catalog_keyspace}.indexes SET propagation_delay_ms = ? \ + WHERE table_id = ? AND index_name = ?" + ); + engine + .session() + .query_with_values( + &cql, + cdrs_tokio::query_values!(delay_ms, table_id, gsi_name), + ) + .await + .expect("set_gsi_delay"); +} + +/// Count rows in `gsi_pending` for a given account keyspace. +async fn gsi_pending_count(engine: &CassandraEngine, account_keyspace: &str) -> usize { + // Filter out static-column-only rows (ready_at=null) which persist after + // all clustering rows are deleted but last_ready_at remains set. + let cql = format!("SELECT id FROM {account_keyspace}.gsi_pending"); + engine + .session() + .query(&cql) + .await + .ok() + .and_then(|f| f.response_body().ok()) + .and_then(|b| b.into_rows()) + .map(|rows| { + use cdrs_tokio::types::IntoRustByName as _; + rows.iter() + .filter(|row| { + let id: Result = row.get_r_by_name("id"); + id.is_ok() + }) + .count() + }) + .unwrap_or(0) +} + +/// Query a GSI and return the count of matching items. +async fn gsi_query_count( + engine: &CassandraEngine, + table: &crate::helpers::TestTable, + gsi_name: &str, + gsi_pk_attr: &str, + gsi_pk_value: &str, +) -> usize { + use extenddb_core::expression::{Expr, KeyCondition, PathElement}; + let key_condition = KeyCondition { + pk_path: vec![PathElement::Attribute(gsi_pk_attr.to_string())], + pk_value: Expr::Placeholder(":v".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + let mut maps = ExpressionMaps::default(); + maps.values.insert( + ":v".to_string(), + AttributeValue::S(gsi_pk_value.to_string()), + ); + engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + Some(gsi_name), + ) + .await + .map(|(items, _)| items.len()) + .unwrap_or(0) +} + +#[tokio::test] +async fn test_async_gsi_enqueues_row_atomically() { + if crate::helpers::skip_without_cassandra() { + return; + } + // A put_item with an async GSI must write a gsi_pending row in the same + // batch as the base write — visible immediately, before the worker runs. + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let table = + crate::helpers::TestTable::with_gsi(&engine, "AsyncGsiEnqueueTable", "GsiIdx", "gpk").await; + + // Long delay: worker will not apply before we check. + set_gsi_delay(&engine, &table.key_info.table_id, "GsiIdx", 30_000).await; + + let account_keyspace = engine.account_keyspace(&table.key_info.account_id); + let maps = ExpressionMaps::default(); + + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S("x".to_string())); + item.insert("gpk".to_string(), AttributeValue::S("g1".to_string())); + + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item"); + + // Row must be in gsi_pending immediately (same batch as base write). + let pending = gsi_pending_count(&engine, &account_keyspace).await; + assert_eq!( + pending, 1, + "gsi_pending row not written atomically with base write" + ); + + // GSI must not yet be visible (worker hasn't run). + let visible = gsi_query_count(&engine, &table, "GsiIdx", "gpk", "g1").await; + assert_eq!(visible, 0, "GSI entry visible before worker ran"); +} + +#[tokio::test] +async fn test_async_gsi_worker_convergence() { + if crate::helpers::skip_without_cassandra() { + return; + } + // Successive writes to the same base item must converge to the latest GSI + // entry — no stale entries from earlier writes. + let config = test_config(); + let engine = Arc::new(CassandraEngine::new(&config, "us-east-1").await.unwrap()); + let table = + crate::helpers::TestTable::with_gsi(&engine, "AsyncGsiConvergeTable", "GsiIdx", "gpk") + .await; + + // Short delay so the worker drains quickly. + set_gsi_delay(&engine, &table.key_info.table_id, "GsiIdx", 50).await; + + let account_keyspace = engine.account_keyspace(&table.key_info.account_id); + let maps = ExpressionMaps::default(); + + // Write the same item 5 times, changing its GSI key each time. + let values = ["v0", "v1", "v2", "v3", "v4"]; + for v in &values { + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S("X".to_string())); + item.insert("gpk".to_string(), AttributeValue::S(v.to_string())); + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item"); + } + + // Spawn workers and wait for the queue to drain. + // Guard is held until end of scope — workers stop when it drops. + let _worker_guard = extenddb_storage_cassandra::workers::spawn_gsi_workers(engine.clone()); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + let count = gsi_pending_count(&engine, &account_keyspace).await; + println!("gsi_pending count: {count}"); + if count == 0 { + break; + } + if tokio::time::Instant::now() >= deadline { + // Print what's actually in the table before panicking + let cql = format!( + "SELECT worker_partition, ready_at, toTimestamp(now()) as now, table_id FROM {account_keyspace}.gsi_pending" + ); + if let Ok(frame) = engine.session().query(&cql).await + && let Ok(body) = frame.response_body() + && let Some(rows) = body.into_rows() + { + use cdrs_tokio::types::IntoRustByName as _; + for row in &rows { + let wp: i32 = row.get_r_by_name("worker_partition").unwrap_or(-1); + let ready_at: i64 = row.get_r_by_name("ready_at").unwrap_or(0); + let now: i64 = row.get_r_by_name("now").unwrap_or(0); + let tid: String = row.get_r_by_name("table_id").unwrap_or_default(); + println!( + " row: partition={wp} ready_at={ready_at} now={now} diff={}ms table={tid}", + now - ready_at + ); + } + } + panic!("gsi_pending did not drain within timeout"); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + // Only the latest GSI key should be present; all earlier ones must be gone. + let latest = values.last().unwrap(); + assert_eq!( + gsi_query_count(&engine, &table, "GsiIdx", "gpk", latest).await, + 1, + "latest GSI entry missing after convergence" + ); + for stale in &values[..values.len() - 1] { + assert_eq!( + gsi_query_count(&engine, &table, "GsiIdx", "gpk", stale).await, + 0, + "stale GSI entry for {stale} survived — updates applied out of order" + ); + } +} + +#[tokio::test] +async fn test_async_gsi_worker_skips_dropped_index() { + if crate::helpers::skip_without_cassandra() { + return; + } + // If the index table is gone (table-deletion race), the worker must consume + // the row (skip + delete) rather than retrying forever. + let config = test_config(); + let engine = Arc::new(CassandraEngine::new(&config, "us-east-1").await.unwrap()); + let table = + crate::helpers::TestTable::with_gsi(&engine, "AsyncGsiDroppedTable", "GsiIdx", "gpk").await; + + set_gsi_delay(&engine, &table.key_info.table_id, "GsiIdx", 50).await; + + let account_keyspace = engine.account_keyspace(&table.key_info.account_id); + let maps = ExpressionMaps::default(); + + // Enqueue a few rows. + for i in 0..3u32 { + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S(format!("pk{i}"))); + item.insert("gpk".to_string(), AttributeValue::S(format!("g{i}"))); + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item"); + } + assert_eq!(gsi_pending_count(&engine, &account_keyspace).await, 3); + + // Look up the index_id and drop the index table. + let catalog_keyspace = engine.catalog_keyspace(); + let cql = format!( + "SELECT index_id FROM {catalog_keyspace}.indexes WHERE table_id = ? AND index_name = ?" + ); + let session = engine.session_arc(); + let rows = extenddb_storage_cassandra::cassandra_util::query_rows::< + extenddb_storage::error::StorageError, + >( + &session, + &cql, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "GsiIdx"), + "test_dropped_index", + ) + .await + .unwrap(); + let index_id: String = extenddb_storage_cassandra::cassandra_util::get_column::< + String, + extenddb_storage::error::StorageError, + >(&rows[0], "index_id", "test_dropped_index") + .unwrap(); + + let drop_cql = format!( + "DROP TABLE IF EXISTS {account_keyspace}.{}", + extenddb_storage_cassandra::data::ddl::index_table_name(&index_id) + ); + engine.session().query(&drop_cql).await.unwrap(); + + // Spawn workers — they must drain the queue without looping. + let _worker_guard = extenddb_storage_cassandra::workers::spawn_gsi_workers(engine.clone()); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + if gsi_pending_count(&engine, &account_keyspace).await == 0 { + break; + } + if tokio::time::Instant::now() >= deadline { + panic!("gsi_pending did not drain after index table was dropped"); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } +} diff --git a/crates/storage-cassandra/tests/direct/metadata_engine.rs b/crates/storage-cassandra/tests/direct/metadata_engine.rs new file mode 100644 index 00000000..53ea8672 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/metadata_engine.rs @@ -0,0 +1,152 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for MetadataEngine tag operations. + +use extenddb_core::types::Tag; +use extenddb_storage::MetadataEngine; + +use crate::helpers::setup_engine; + +#[tokio::test] +async fn test_tag_and_list_tags() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let arn = format!( + "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", + uuid::Uuid::new_v4().simple() + ); + + let tags = vec![ + Tag { + key: "env".to_string(), + value: "test".to_string(), + }, + Tag { + key: "owner".to_string(), + value: "alice".to_string(), + }, + ]; + + engine + .tag_resource(&arn, &tags) + .await + .expect("tag_resource should succeed"); + + let result = engine + .list_tags(&arn) + .await + .expect("list_tags should succeed"); + assert_eq!(result.len(), 2); + // Cassandra returns in clustering key order + assert_eq!(result[0].key, "env"); + assert_eq!(result[0].value, "test"); + assert_eq!(result[1].key, "owner"); + assert_eq!(result[1].value, "alice"); +} + +#[tokio::test] +async fn test_tag_resource_upserts() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let arn = format!( + "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", + uuid::Uuid::new_v4().simple() + ); + + engine + .tag_resource( + &arn, + &[Tag { + key: "env".to_string(), + value: "staging".to_string(), + }], + ) + .await + .expect("first tag_resource should succeed"); + + // Overwrite with new value + engine + .tag_resource( + &arn, + &[Tag { + key: "env".to_string(), + value: "prod".to_string(), + }], + ) + .await + .expect("second tag_resource should succeed"); + + let result = engine + .list_tags(&arn) + .await + .expect("list_tags should succeed"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].value, "prod"); +} + +#[tokio::test] +async fn test_untag_resource() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let arn = format!( + "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", + uuid::Uuid::new_v4().simple() + ); + + let tags = vec![ + Tag { + key: "a".to_string(), + value: "1".to_string(), + }, + Tag { + key: "b".to_string(), + value: "2".to_string(), + }, + Tag { + key: "c".to_string(), + value: "3".to_string(), + }, + ]; + engine + .tag_resource(&arn, &tags) + .await + .expect("tag_resource should succeed"); + + engine + .untag_resource(&arn, &["a".to_string(), "c".to_string()]) + .await + .expect("untag_resource should succeed"); + + let result = engine + .list_tags(&arn) + .await + .expect("list_tags should succeed"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].key, "b"); + assert_eq!(result[0].value, "2"); +} + +#[tokio::test] +async fn test_list_tags_empty() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let arn = format!( + "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", + uuid::Uuid::new_v4().simple() + ); + + let result = engine + .list_tags(&arn) + .await + .expect("list_tags should succeed"); + assert!(result.is_empty()); +} diff --git a/crates/storage-cassandra/tests/direct/policies.rs b/crates/storage-cassandra/tests/direct/policies.rs new file mode 100644 index 00000000..38136924 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/policies.rs @@ -0,0 +1,84 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for Policies management operations for `CassandraCatalogStore`. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_put_policy() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + let policy_doc = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "dynamodb:*", + "Resource": "*" + }] + }); + + catalog_store + .put_policy(&account_id, "user", &user_name, "TestPolicy", &policy_doc) + .await + .expect("Failed to put policy"); + + println!("✓ Policy created successfully"); + + let updated_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "dynamodb:GetItem", + "Resource": "*" + }] + }); + + catalog_store + .put_policy( + &account_id, + "user", + &user_name, + "TestPolicy", + &updated_policy, + ) + .await + .expect("Failed to update policy"); + + // Read back: the update must be visible, not just accepted. + let policies = catalog_store + .list_policies(&account_id, "user", &user_name) + .await + .expect("Failed to list policies"); + let (_, stored_document, _) = policies + .iter() + .find(|(name, _, _)| name == "TestPolicy") + .expect("updated policy missing from list"); + assert!( + stored_document.to_string().contains("dynamodb:GetItem"), + "policy read-back does not reflect the update: {stored_document}" + ); + } +} diff --git a/crates/storage-cassandra/tests/direct/put_get_item.rs b/crates/storage-cassandra/tests/direct/put_get_item.rs new file mode 100644 index 00000000..df918654 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/put_get_item.rs @@ -0,0 +1,708 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for put_item and get_item operations. + +use extenddb_core::types::{AttributeValue, ScalarAttributeType}; +use extenddb_storage::DataEngine; +use std::collections::BTreeMap; + +use crate::helpers::{TestTable, setup_engine}; + +#[tokio::test] +async fn test_put_and_get_item_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TestPkOnlyTable", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("test-id-1".to_string())); + item.insert( + "name".to_string(), + AttributeValue::S("Test Item".to_string()), + ); + item.insert("count".to_string(), AttributeValue::N("42".to_string())); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put failed"); + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("test-id-1".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("Get failed") + .expect("Item should exist"); + + assert_eq!(retrieved.get("id"), item.get("id")); + assert_eq!(retrieved.get("name"), item.get("name")); + assert_eq!(retrieved.get("count"), item.get("count")); +} + +#[tokio::test] +async fn test_put_and_get_item_with_string_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TestStringSkTable", true).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("partition1".to_string()), + ); + item.insert("sort".to_string(), AttributeValue::S("sort1".to_string())); + item.insert( + "data".to_string(), + AttributeValue::S("test data".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put failed"); + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("partition1".to_string()), + ); + key.insert("sort".to_string(), AttributeValue::S("sort1".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("Get failed") + .expect("Item should exist"); + + assert_eq!( + retrieved.get("data"), + Some(&AttributeValue::S("test data".to_string())) + ); +} + +#[tokio::test] +async fn test_put_and_get_item_with_number_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "TestNumberSkTable", ScalarAttributeType::N).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("user-1".to_string())); + item.insert("sort".to_string(), AttributeValue::N("100".to_string())); + item.insert("value".to_string(), AttributeValue::S("data".to_string())); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put failed"); + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("user-1".to_string())); + key.insert("sort".to_string(), AttributeValue::N("100".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("Get failed") + .expect("Item should exist"); + + assert_eq!(retrieved.get("value"), item.get("value")); +} + +#[tokio::test] +async fn test_put_and_get_item_with_decimal_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + // Regression test for the numeric-sort-key decimal support (Technical + // Debt #1). DynamoDB's N type is an arbitrary-precision decimal; the + // `sk_n` column is now `decimal` and N values bind as a real Cassandra + // decimal, so fractional/high-precision sort keys must round-trip exactly. + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "TestDecimalSkTable", ScalarAttributeType::N).await; + + // A range of values that a varint column or string binding could not + // represent: fractions, negatives, and a high-precision value. + let decimal_keys = [ + "123.456", + "0.0000000001", + "-42.5", + "3.14159265358979323846", + "1000000000000.000001", + ]; + + for (i, sk) in decimal_keys.iter().enumerate() { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("dec-pk".to_string())); + item.insert("sort".to_string(), AttributeValue::N((*sk).to_string())); + item.insert( + "value".to_string(), + AttributeValue::S(format!("payload-{i}")), + ); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item with decimal sort key should succeed"); + } + + // Each decimal sort key must fetch its exact item back. + for (i, sk) in decimal_keys.iter().enumerate() { + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("dec-pk".to_string())); + key.insert("sort".to_string(), AttributeValue::N((*sk).to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item should succeed") + .unwrap_or_else(|| panic!("item with decimal sort key {sk} should exist")); + + assert_eq!( + retrieved.get("value"), + Some(&AttributeValue::S(format!("payload-{i}"))), + "decimal sort key {sk} did not round-trip to the correct item" + ); + // The sort key attribute itself is stored in item_data and must be + // byte-identical to what was written (no precision loss). + assert_eq!( + retrieved.get("sort"), + Some(&AttributeValue::N((*sk).to_string())), + "decimal sort key {sk} lost precision on round-trip" + ); + } +} + +#[tokio::test] +async fn test_put_and_get_item_with_binary_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "TestBinarySkTable", ScalarAttributeType::B).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("record-1".to_string())); + item.insert("sort".to_string(), AttributeValue::B(vec![1, 2, 3, 4])); + item.insert( + "info".to_string(), + AttributeValue::S("binary key test".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put failed"); + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("record-1".to_string())); + key.insert("sort".to_string(), AttributeValue::B(vec![1, 2, 3, 4])); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("Get failed") + .expect("Item should exist"); + + assert_eq!(retrieved.get("info"), item.get("info")); +} + +#[tokio::test] +async fn test_put_item_update_existing() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TestUpdateTable", false).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("update-test".to_string()), + ); + item.insert("value".to_string(), AttributeValue::N("1".to_string())); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("First put failed"); + + item.insert("value".to_string(), AttributeValue::N("2".to_string())); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Second put failed"); + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("update-test".to_string()), + ); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("Get failed") + .expect("Item should exist"); + + assert_eq!( + retrieved.get("value"), + Some(&AttributeValue::N("2".to_string())) + ); +} + +#[tokio::test] +async fn test_get_item_not_found() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TestNotFoundTable", false).await; + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("nonexistent".to_string()), + ); + + let result = engine + .get_item(&table.key_info, &key) + .await + .expect("Get should succeed"); + + assert!(result.is_none()); +} + +#[tokio::test] +async fn test_put_item_with_return_old() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TestReturnOldTable", false).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("return-test".to_string()), + ); + item.insert("version".to_string(), AttributeValue::N("1".to_string())); + + let first_result = engine + .put_item( + &table.key_info, + item.clone(), + true, + None, + &Default::default(), + None, + ) + .await + .expect("First put failed"); + + assert!(first_result.is_none()); + + item.insert("version".to_string(), AttributeValue::N("2".to_string())); + + let second_result = engine + .put_item( + &table.key_info, + item.clone(), + true, + None, + &Default::default(), + None, + ) + .await + .expect("Second put failed"); + + assert!(second_result.is_some()); + let old = second_result.unwrap(); + assert_eq!( + old.get("version"), + Some(&AttributeValue::N("1".to_string())) + ); +} + +#[tokio::test] +async fn test_put_item_with_sync_gsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + use crate::helpers::TestTable; + + let engine = setup_engine().await; + let table = TestTable::with_gsi(&engine, "TestGSITable", "TestGSI", "gsi_pk").await; + + // The in-tree default GSI propagation is asynchronous (10ms via the GSI + // queue, drained by a worker that does not run in these tests). This test + // is about the SYNCHRONOUS write path, so pin the index's delay to 0 — + // the same knob the async/sync routing reads in production. + let pin_sync = format!( + "UPDATE extenddb_ttl_test_catalog.indexes SET propagation_delay_ms = 0 \ + WHERE table_id = '{}' AND index_name = 'TestGSI'", + table.key_info.table_id + ); + engine + .session_arc() + .query(pin_sync) + .await + .expect("pin GSI to synchronous propagation"); + + // Put an item with the GSI key + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("item-1".to_string())); + item.insert( + "gsi_pk".to_string(), + AttributeValue::S("gsi-value-1".to_string()), + ); + item.insert( + "data".to_string(), + AttributeValue::S("test data".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item with GSI failed"); + + // Verify item exists in base table + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("item-1".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item failed") + .expect("Item should exist"); + + assert_eq!(retrieved.get("gsi_pk"), item.get("gsi_pk")); + assert_eq!(retrieved.get("data"), item.get("data")); + + // Verify the index row exists via the public index-scan path (the write + // is synchronous, so the row must be visible immediately). + let (index_rows, _) = engine + .scan(&table.key_info, None, None, None, None, Some("TestGSI")) + .await + .expect("index scan failed"); + assert!( + index_rows.iter().any(|row| { + row.get("gsi_pk") == Some(&AttributeValue::S("gsi-value-1".to_string())) + && row.get("id") == Some(&AttributeValue::S("item-1".to_string())) + }), + "synchronously-written GSI row missing from index scan: {index_rows:?}" + ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Transaction protection tests (Phase 3, T3.1) +// ═══════════════════════════════════════════════════════════════════════════════ + +use crate::helpers::put_item_then_lock; + +#[tokio::test] +async fn test_put_item_rejects_when_prepared_txn_id_set_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtPutPk", false).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("locked-item".to_string()), + ); + item.insert("value".to_string(), AttributeValue::N("1".to_string())); + + put_item_then_lock(&engine, &table, &item).await; + + // Try to overwrite the locked item + let mut new_item = BTreeMap::new(); + new_item.insert( + "id".to_string(), + AttributeValue::S("locked-item".to_string()), + ); + new_item.insert("value".to_string(), AttributeValue::N("2".to_string())); + + let result = engine + .put_item( + &table.key_info, + new_item, + false, + None, + &Default::default(), + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_put_item_rejects_when_prepared_txn_id_set_with_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtPutSk", true).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + item.insert("value".to_string(), AttributeValue::N("1".to_string())); + + put_item_then_lock(&engine, &table, &item).await; + + // Try to overwrite the locked item + let mut new_item = BTreeMap::new(); + new_item.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + new_item.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + new_item.insert("value".to_string(), AttributeValue::N("99".to_string())); + + let result = engine + .put_item( + &table.key_info, + new_item, + false, + None, + &Default::default(), + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_put_item_succeeds_when_prepared_txn_id_is_null() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtPutOk", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("unlocked".to_string())); + item.insert("value".to_string(), AttributeValue::N("1".to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("First put should succeed"); + + // Overwrite should also succeed (no transaction lock) + let mut item2 = BTreeMap::new(); + item2.insert("id".to_string(), AttributeValue::S("unlocked".to_string())); + item2.insert("value".to_string(), AttributeValue::N("2".to_string())); + + engine + .put_item( + &table.key_info, + item2, + false, + None, + &Default::default(), + None, + ) + .await + .expect("Overwrite of unlocked item should succeed"); +} + +#[tokio::test] +async fn test_update_item_rejects_when_prepared_txn_id_set_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_core::expression::{Expr, ExpressionMaps, PathElement, UpdateAction}; + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtUpdPk", false).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("upd-locked".to_string()), + ); + item.insert("counter".to_string(), AttributeValue::N("10".to_string())); + + put_item_then_lock(&engine, &table, &item).await; + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("upd-locked".to_string()), + ); + + let actions = vec![UpdateAction::Set { + path: vec![PathElement::Attribute("counter".to_string())], + value: Expr::Placeholder("val".to_string()), + }]; + + let mut values = std::collections::HashMap::new(); + values.insert("val".to_string(), AttributeValue::N("20".to_string())); + let maps = ExpressionMaps::new(std::collections::HashMap::new(), values); + + let result = engine + .update_item( + &table.key_info, + &key, + &actions, + false, + false, + None, + &maps, + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_update_item_rejects_when_prepared_txn_id_set_with_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_core::expression::{Expr, ExpressionMaps, PathElement, UpdateAction}; + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtUpdSk", true).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("upk1".to_string())); + item.insert("sort".to_string(), AttributeValue::S("usk1".to_string())); + item.insert("counter".to_string(), AttributeValue::N("5".to_string())); + + put_item_then_lock(&engine, &table, &item).await; + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("upk1".to_string())); + key.insert("sort".to_string(), AttributeValue::S("usk1".to_string())); + + let actions = vec![UpdateAction::Set { + path: vec![PathElement::Attribute("counter".to_string())], + value: Expr::Placeholder("val".to_string()), + }]; + + let mut values = std::collections::HashMap::new(); + values.insert("val".to_string(), AttributeValue::N("99".to_string())); + let maps = ExpressionMaps::new(std::collections::HashMap::new(), values); + + let result = engine + .update_item( + &table.key_info, + &key, + &actions, + false, + false, + None, + &maps, + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} diff --git a/crates/storage-cassandra/tests/direct/query.rs b/crates/storage-cassandra/tests/direct/query.rs new file mode 100644 index 00000000..05baa9e9 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/query.rs @@ -0,0 +1,1509 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for Query operation. + +use extenddb_core::expression::{CompareOp, Expr, ExpressionMaps, KeyCondition}; +use extenddb_core::types::AttributeValue; +use extenddb_storage::DataEngine; +use std::collections::BTreeMap; + +use crate::helpers::{TestTable, setup_engine}; + +#[tokio::test] +async fn test_query_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryTestTable", false).await; + + // Put three items with the same partition key (PK-only table, so overwrites) + let pk_value = AttributeValue::S("user123".to_string()); + + for i in 1..=3 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("name".to_string(), AttributeValue::S(format!("Item {}", i))); + item.insert("value".to_string(), AttributeValue::N(i.to_string())); + + let maps = ExpressionMaps::default(); + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item should succeed"); + } + + // Query by partition key only + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + + let (items, last_key) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // For PK-only table, we should get back just one item (the last write wins) + assert_eq!( + items.len(), + 1, + "PK-only table should return single item per partition key" + ); + + // Verify the item contains our partition key and last written data + assert_eq!(items[0].get("id"), Some(&pk_value)); + assert_eq!( + items[0].get("value"), + Some(&AttributeValue::N("3".to_string())) + ); + + // No pagination for single item + assert!(last_key.is_none(), "Should not have pagination key"); + + println!("✓ PK-only query test passed"); +} + +#[tokio::test] +async fn test_query_with_sk_equals() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QuerySkTable", true).await; + + // Put multiple items with same PK but different SKs + let pk_value = AttributeValue::S("user123".to_string()); + + for i in 1..=5 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert( + "sort".to_string(), + AttributeValue::S(format!("item-{:02}", i)), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query for specific PK + SK combination + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Compare { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + op: extenddb_core::expression::CompareOp::Eq, + value: Expr::Placeholder(":sk".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":sk".to_string(), AttributeValue::S("item-03".to_string())); + + let (items, last_key) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // Should get exactly one item + assert_eq!(items.len(), 1, "Should return exactly one item"); + assert_eq!(items[0].get("id"), Some(&pk_value)); + assert_eq!( + items[0].get("sort"), + Some(&AttributeValue::S("item-03".to_string())) + ); + assert_eq!( + items[0].get("data"), + Some(&AttributeValue::N("3".to_string())) + ); + assert!(last_key.is_none(), "Should not have pagination key"); + + println!("✓ SK equality query test passed"); +} + +#[tokio::test] +async fn test_query_with_sk_comparison() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QuerySkCompareTable", true).await; + + // Put items with numeric sort keys + let pk_value = AttributeValue::S("partition1".to_string()); + + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("sort".to_string(), AttributeValue::S(i.to_string())); + item.insert("value".to_string(), AttributeValue::N((i * 10).to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Test GT (greater than) - should get items > 5 + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Compare { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + op: extenddb_core::expression::CompareOp::Gt, + value: Expr::Placeholder(":sk".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":sk".to_string(), AttributeValue::S("5".to_string())); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // String comparison: "6", "7", "8", "9" are > "5", but "10" is not (lexicographic order) + // So we should get 4 items + println!("Got {} items", items.len()); + for item in &items { + if let Some(AttributeValue::S(s)) = item.get("sort") { + println!(" sort: {}", s); + } + } + + assert_eq!( + items.len(), + 4, + "Should return 4 items with sort > '5' (string comparison)" + ); + + // Verify all returned items have sort > "5" + for item in &items { + let sort_val = item.get("sort").expect("item should have sort key"); + if let AttributeValue::S(s) = sort_val { + assert!(s.as_str() > "5", "Item sort key '{}' should be > '5'", s); + } + } + + println!("✓ SK comparison query test passed"); +} +#[tokio::test] +async fn test_query_with_numeric_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + use crate::helpers::TestTable; + + let engine = setup_engine().await; + + // Create table with numeric sort key + let table = TestTable::with_sort_key_type( + &engine, + "QueryNumericSkTable", + extenddb_core::types::ScalarAttributeType::N, + ) + .await; + + // Put items with numeric sort keys 1-10 + let pk_value = AttributeValue::S("sensor1".to_string()); + + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("sort".to_string(), AttributeValue::N(i.to_string())); + item.insert( + "reading".to_string(), + AttributeValue::N((i * 100).to_string()), + ); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query for sort > 5 (numeric comparison) + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Compare { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + op: extenddb_core::expression::CompareOp::Gt, + value: Expr::Placeholder(":sk".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":sk".to_string(), AttributeValue::N("5".to_string())); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // With numeric comparison: 6, 7, 8, 9, 10 are all > 5, so we should get 5 items + println!("Got {} items with numeric sort keys", items.len()); + for item in &items { + if let Some(AttributeValue::N(n)) = item.get("sort") { + println!(" sort: {}", n); + } + } + + assert_eq!( + items.len(), + 5, + "Should return 5 items with sort > 5 (numeric comparison)" + ); + + // Verify all returned items have sort > 5 + for item in &items { + let sk_val = item.get("sort").expect("item should have sort key"); + if let AttributeValue::N(n) = sk_val { + let num: i32 = n.parse().expect("should be valid number"); + assert!(num > 5, "Item sort {} should be > 5", num); + } + } + + println!("✓ Numeric SK comparison query test passed"); +} + +#[tokio::test] +async fn test_query_with_decimal_sk_range_and_order() { + if crate::helpers::skip_without_cassandra() { + return; + } + // Verifies that fractional decimal sort keys compare and order correctly at + // the column level (Technical Debt #1). The previous varint column + string + // binding could not represent fractions and broke `sk_n` comparisons; this + // exercises a real numeric range predicate over decimals plus ascending + // clustering order. + let engine = setup_engine().await; + let table = TestTable::with_sort_key_type( + &engine, + "QueryDecimalSkTable", + extenddb_core::types::ScalarAttributeType::N, + ) + .await; + + let pk_value = AttributeValue::S("sensor-d".to_string()); + + // Insert out of order to prove ordering comes from the decimal column, not + // insertion order. Mix of fractions and integers. + let sort_values = ["10.5", "0.25", "2.5", "2.05", "100", "2.500001"]; + for sk in sort_values { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("sort".to_string(), AttributeValue::N(sk.to_string())); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query: sort > 2.5 → expect 2.500001, 10.5, 100 (NOT 2.5, 2.05, 0.25). + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Compare { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + op: extenddb_core::expression::CompareOp::Gt, + value: Expr::Placeholder(":sk".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":sk".to_string(), AttributeValue::N("2.5".to_string())); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, // ascending + None, + None, + None, + ) + .await + .expect("decimal range query should succeed"); + + let got: Vec = items + .iter() + .map(|item| match item.get("sort") { + Some(AttributeValue::N(n)) => n.clone(), + other => panic!("expected numeric sort, got {other:?}"), + }) + .collect(); + + // Strictly greater than 2.5, returned in ascending decimal order. + assert_eq!( + got, + vec![ + "2.500001".to_string(), + "10.5".to_string(), + "100".to_string() + ], + "decimal range filter + ordering incorrect" + ); + + println!("✓ Decimal SK range + ordering query test passed"); +} + +#[tokio::test] +async fn test_query_ordering() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryOrderTable", true).await; + + // Put items out of order + let pk_value = AttributeValue::S("partition1".to_string()); + let sort_values = vec!["apple", "zebra", "banana", "mango", "cherry"]; + + for sort in &sort_values { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("sort".to_string(), AttributeValue::S(sort.to_string())); + item.insert( + "data".to_string(), + AttributeValue::S(format!("Item {}", sort)), + ); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query with forward=true (ascending order) + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + + let (items_asc, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("forward query should succeed"); + + // Verify ascending order + let sort_keys_asc: Vec = items_asc + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("sort") { + s.clone() + } else { + panic!("Expected string sort key") + } + }) + .collect(); + + println!("Ascending order: {:?}", sort_keys_asc); + assert_eq!( + sort_keys_asc, + vec!["apple", "banana", "cherry", "mango", "zebra"] + ); + + // Query with forward=false (descending order) + let (items_desc, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + false, + None, + None, + None, + ) + .await + .expect("reverse query should succeed"); + + // Verify descending order + let sort_keys_desc: Vec = items_desc + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("sort") { + s.clone() + } else { + panic!("Expected string sort key") + } + }) + .collect(); + + println!("Descending order: {:?}", sort_keys_desc); + assert_eq!( + sort_keys_desc, + vec!["zebra", "mango", "cherry", "banana", "apple"] + ); + + println!("✓ Query ordering test passed"); +} + +#[tokio::test] +async fn test_query_limit() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryLimitTable", true).await; + + // Put 10 items + let pk_value = AttributeValue::S("partition1".to_string()); + + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert( + "sort".to_string(), + AttributeValue::S(format!("item-{:02}", i)), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query with limit=3 + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + + let (items, last_key) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + None, + None, + ) + .await + .expect("query should succeed"); + + println!("Got {} items with limit=3", items.len()); + assert_eq!(items.len(), 3, "Should return exactly 3 items"); + + // Should have last_key since there are more items + assert!(last_key.is_some(), "Should have LastEvaluatedKey"); + + // Verify we got the first 3 items + assert_eq!( + items[0].get("sort"), + Some(&AttributeValue::S("item-01".to_string())) + ); + assert_eq!( + items[1].get("sort"), + Some(&AttributeValue::S("item-02".to_string())) + ); + assert_eq!( + items[2].get("sort"), + Some(&AttributeValue::S("item-03".to_string())) + ); + + println!("✓ Query limit test passed"); +} + +#[tokio::test] +async fn test_query_pagination() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryPaginationTable", true).await; + + // Put 10 items + let pk_value = AttributeValue::S("partition1".to_string()); + + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert( + "sort".to_string(), + AttributeValue::S(format!("item-{:02}", i)), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + + // First page: get 3 items + let (page1, last_key1) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + None, + None, + ) + .await + .expect("first query should succeed"); + + assert_eq!(page1.len(), 3); + assert!( + last_key1.is_some(), + "Should have LastEvaluatedKey after page 1" + ); + println!("Page 1: {} items", page1.len()); + + // Second page: use last_key as exclusive start + let (page2, last_key2) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key1.as_ref(), + None, + ) + .await + .expect("second query should succeed"); + + assert_eq!(page2.len(), 3); + assert!( + last_key2.is_some(), + "Should have LastEvaluatedKey after page 2" + ); + println!("Page 2: {} items", page2.len()); + + // Third page + let (page3, last_key3) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key2.as_ref(), + None, + ) + .await + .expect("third query should succeed"); + + assert_eq!(page3.len(), 3); + assert!( + last_key3.is_some(), + "Should have LastEvaluatedKey after page 3" + ); + println!("Page 3: {} items", page3.len()); + + // Fourth page: should get remaining item + let (page4, last_key4) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key3.as_ref(), + None, + ) + .await + .expect("fourth query should succeed"); + + assert_eq!(page4.len(), 1); + assert!( + last_key4.is_none(), + "Should NOT have LastEvaluatedKey after last page" + ); + println!("Page 4: {} items (final)", page4.len()); + + // Verify no duplicates and correct ordering + let all_items: Vec = [page1, page2, page3, page4] + .concat() + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("sort") { + s.clone() + } else { + panic!("Expected string sort key") + } + }) + .collect(); + + assert_eq!(all_items.len(), 10, "Should have retrieved all 10 items"); + assert_eq!( + all_items, + vec![ + "item-01", "item-02", "item-03", "item-04", "item-05", "item-06", "item-07", "item-08", + "item-09", "item-10" + ] + ); + + println!("✓ Query pagination test passed"); +} + +#[tokio::test] +async fn test_query_between() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryBetweenTable", true).await; + + // Put 10 items + let pk_value = AttributeValue::S("partition1".to_string()); + + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert( + "sort".to_string(), + AttributeValue::S(format!("item-{:02}", i)), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query for items with sort BETWEEN "item-03" AND "item-07" + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Between { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + low: Expr::Placeholder(":low".to_string()), + high: Expr::Placeholder(":high".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":low".to_string(), AttributeValue::S("item-03".to_string())); + maps.values.insert( + ":high".to_string(), + AttributeValue::S("item-07".to_string()), + ); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // Should get items 03, 04, 05, 06, 07 (5 items) + println!("Got {} items with BETWEEN", items.len()); + assert_eq!( + items.len(), + 5, + "Should return 5 items between item-03 and item-07" + ); + + let sort_keys: Vec = items + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("sort") { + s.clone() + } else { + panic!("Expected string sort key") + } + }) + .collect(); + + assert_eq!( + sort_keys, + vec!["item-03", "item-04", "item-05", "item-06", "item-07"] + ); + + println!("✓ Query BETWEEN test passed"); +} + +#[tokio::test] +async fn test_query_begins_with() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryBeginsWithTable", true).await; + + // Put items with various prefixes + let pk_value = AttributeValue::S("partition1".to_string()); + let items = vec!["apple", "apricot", "application", "banana", "berry", "cat"]; + + for item_name in &items { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("sort".to_string(), AttributeValue::S(item_name.to_string())); + item.insert( + "data".to_string(), + AttributeValue::S(format!("Item {}", item_name)), + ); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query for items that begin with "app" + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::BeginsWith { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + prefix: Expr::Placeholder(":prefix".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":prefix".to_string(), AttributeValue::S("app".to_string())); + + let (items_result, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // Should get apple, application (both start with "app") + println!("Got {} items with begins_with('app')", items_result.len()); + assert_eq!( + items_result.len(), + 2, + "Should return 2 items beginning with 'app'" + ); + + let sort_keys: Vec = items_result + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("sort") { + s.clone() + } else { + panic!("Expected string sort key") + } + }) + .collect(); + + assert_eq!(sort_keys, vec!["apple", "application"]); + + println!("✓ Query begins_with test passed"); +} + +#[tokio::test] +async fn test_query_gsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::with_gsi(&engine, "QueryGsiTable", "StatusIndex", "status").await; + + // Set GSI to synchronous (propagation_delay_ms = 0) for testing + let catalog_keyspace = engine.catalog_keyspace(); + let update_delay = format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &update_delay, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "StatusIndex"), + ) + .await + .expect("Failed to set GSI to synchronous"); + + println!("✓ Table created with synchronous GSI: StatusIndex"); + println!(" Table ID: {}", table.key_info.table_id); + println!(" Account ID: {}", table.key_info.account_id); + + // Put items with different status values + let items_data = vec![ + ("item1", "active"), + ("item2", "pending"), + ("item3", "active"), + ("item4", "inactive"), + ]; + + let maps = ExpressionMaps::default(); + for (id, status) in items_data { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id.to_string())); + item.insert("status".to_string(), AttributeValue::S(status.to_string())); + item.insert( + "data".to_string(), + AttributeValue::S(format!("Data for {}", id)), + ); + + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item should succeed"); + + println!("✓ Put item: {} with status={}", id, status); + } + + // Query GSI by status = "active" + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "status".to_string(), + )], + pk_value: Expr::Placeholder(":status".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert( + ":status".to_string(), + AttributeValue::S("active".to_string()), + ); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + Some("StatusIndex"), + ) + .await + .expect("query GSI should succeed"); + + assert_eq!(items.len(), 2, "Should return 2 items with status=active"); + + let ids: Vec = items + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("id") { + s.clone() + } else { + panic!("Expected string id") + } + }) + .collect(); + + assert!(ids.contains(&"item1".to_string())); + assert!(ids.contains(&"item3".to_string())); + + println!("✓ Query GSI test passed"); +} + +#[tokio::test] +async fn test_query_gsi_pagination() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_gsi(&engine, "QueryGsiPaginationTable", "StatusIndex", "status").await; + + // Set GSI to synchronous for testing + let catalog_keyspace = engine.catalog_keyspace(); + let update_delay = format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &update_delay, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "StatusIndex"), + ) + .await + .expect("Failed to set GSI to synchronous"); + + println!("✓ Table created with synchronous GSI for pagination test"); + + // Put 10 items all with status="active" to test pagination with same index PK + // This forces the two-query pagination logic (base table keys as tie-breakers) + let maps = ExpressionMaps::default(); + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(format!("item{:02}", i))); + item.insert( + "status".to_string(), + AttributeValue::S("active".to_string()), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item should succeed"); + } + + println!("✓ Put 10 items all with status=active"); + + // Query GSI with pagination + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "status".to_string(), + )], + pk_value: Expr::Placeholder(":status".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert( + ":status".to_string(), + AttributeValue::S("active".to_string()), + ); + + // Page 1: Get 3 items + let (page1, last_key1) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + None, + Some("StatusIndex"), + ) + .await + .expect("first GSI query should succeed"); + + assert_eq!(page1.len(), 3, "Page 1 should have 3 items"); + assert!(last_key1.is_some(), "Should have LastEvaluatedKey"); + println!("✓ Page 1: {} items", page1.len()); + + // Page 2: Continue pagination + let (page2, last_key2) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key1.as_ref(), + Some("StatusIndex"), + ) + .await + .expect("second GSI query should succeed"); + + assert_eq!(page2.len(), 3, "Page 2 should have 3 items"); + assert!(last_key2.is_some(), "Should have LastEvaluatedKey"); + println!("✓ Page 2: {} items", page2.len()); + + // Page 3: Continue pagination + let (page3, last_key3) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key2.as_ref(), + Some("StatusIndex"), + ) + .await + .expect("third GSI query should succeed"); + + assert_eq!(page3.len(), 3, "Page 3 should have 3 items"); + assert!(last_key3.is_some(), "Should have LastEvaluatedKey"); + println!("✓ Page 3: {} items", page3.len()); + + // Page 4: Get remaining item + let (page4, last_key4) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key3.as_ref(), + Some("StatusIndex"), + ) + .await + .expect("fourth GSI query should succeed"); + + assert_eq!(page4.len(), 1, "Page 4 should have 1 remaining item"); + assert!(last_key4.is_none(), "Should not have more pages"); + println!("✓ Page 4: {} items (final page)", page4.len()); + + // Verify all items are unique (no duplicates from pagination) + let mut all_ids: Vec = Vec::new(); + for items in &[&page1, &page2, &page3, &page4] { + for item in items.iter() { + if let Some(AttributeValue::S(id)) = item.get("id") { + all_ids.push(id.clone()); + } + } + } + + all_ids.sort(); + let unique_count = all_ids + .iter() + .collect::>() + .len(); + assert_eq!( + unique_count, 10, + "Should have 10 unique items across all pages" + ); + assert_eq!(all_ids.len(), 10, "Should have exactly 10 items total"); + + println!("✓ GSI pagination test passed - all 10 items retrieved without duplicates"); +} + +#[tokio::test] +async fn test_query_lsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::with_lsi(&engine, "QueryLsiTable", "LsiPriority", "priority").await; + + println!("✓ Table created with LSI: LsiPriority"); + + // Put items with same id but different sort keys and priorities + let items_data = vec![ + ("user1", 1000, 3), // sort=1000, priority=3 + ("user1", 2000, 1), // sort=2000, priority=1 (highest priority) + ("user1", 3000, 5), // sort=3000, priority=5 (lowest priority) + ("user1", 4000, 2), // sort=4000, priority=2 + ]; + + let maps = ExpressionMaps::default(); + for (id, sort, priority) in items_data { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id.to_string())); + item.insert("sort".to_string(), AttributeValue::N(sort.to_string())); + item.insert( + "priority".to_string(), + AttributeValue::N(priority.to_string()), + ); + + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item should succeed"); + } + + println!("✓ Put 4 items with different priorities"); + + // Query LSI by id, ordered by priority + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":id".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values + .insert(":id".to_string(), AttributeValue::S("user1".to_string())); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + Some("LsiPriority"), + ) + .await + .expect("query LSI should succeed"); + + assert_eq!(items.len(), 4, "Should return 4 items"); + + // Verify items are ordered by priority + let priorities: Vec = items + .iter() + .map(|item| { + if let Some(AttributeValue::N(n)) = item.get("priority") { + n.parse().unwrap() + } else { + panic!("Expected numeric priority") + } + }) + .collect(); + + assert_eq!( + priorities, + vec![1, 2, 3, 5], + "Should be ordered by priority ASC" + ); + + println!("✓ LSI query test passed - items ordered by priority"); +} + +#[tokio::test] +async fn test_query_gsi_with_sort_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::with_gsi_with_sk( + &engine, + "QueryGsiSkTable", + "CategoryTimeIndex", + "category", + "created_at", + ) + .await; + + // Set GSI to synchronous + let catalog_keyspace = engine.catalog_keyspace(); + let update_delay = format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &update_delay, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "CategoryTimeIndex"), + ) + .await + .expect("Failed to set GSI to synchronous"); + + println!("✓ Table created with GSI with sort key: CategoryTimeIndex"); + + // Put items with same category but different timestamps + let items_data = vec![ + ("item1", "books", 1000), + ("item2", "books", 2000), + ("item3", "books", 3000), + ("item4", "books", 4000), + ("item5", "electronics", 1500), + ]; + + let maps = ExpressionMaps::default(); + for (id, category, timestamp) in items_data { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id.to_string())); + item.insert( + "category".to_string(), + AttributeValue::S(category.to_string()), + ); + item.insert( + "created_at".to_string(), + AttributeValue::N(timestamp.to_string()), + ); + + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item should succeed"); + } + + println!("✓ Put 5 items with different categories and timestamps"); + + // Query GSI without SK condition first to see if items are in the index + let key_condition_simple = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "category".to_string(), + )], + pk_value: Expr::Placeholder(":category".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps_simple = ExpressionMaps::default(); + maps_simple.values.insert( + ":category".to_string(), + AttributeValue::S("books".to_string()), + ); + + let (items_simple, _) = engine + .query( + &table.key_info, + &key_condition_simple, + &maps_simple, + true, + None, + None, + Some("CategoryTimeIndex"), + ) + .await + .expect("simple GSI query should succeed"); + + println!("✓ Simple query returned {} items", items_simple.len()); + assert_eq!( + items_simple.len(), + 4, + "Should have 4 items with category=books" + ); + + // Query GSI: category = "books" AND created_at >= 2000 + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "category".to_string(), + )], + pk_value: Expr::Placeholder(":category".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Compare { + path: vec![extenddb_core::expression::PathElement::Attribute( + "created_at".to_string(), + )], + op: CompareOp::Ge, + value: Expr::Placeholder(":min_time".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert( + ":category".to_string(), + AttributeValue::S("books".to_string()), + ); + maps.values.insert( + ":min_time".to_string(), + AttributeValue::N("2000".to_string()), + ); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + Some("CategoryTimeIndex"), + ) + .await + .expect("query GSI with SK condition should succeed"); + + assert_eq!(items.len(), 3, "Should return 3 items (2000, 3000, 4000)"); + + // Verify items are in the correct range + let timestamps: Vec = items + .iter() + .map(|item| { + if let Some(AttributeValue::N(n)) = item.get("created_at") { + n.parse().unwrap() + } else { + panic!("Expected numeric created_at") + } + }) + .collect(); + + assert_eq!( + timestamps, + vec![2000, 3000, 4000], + "Should have timestamps >= 2000 in order" + ); + + println!("✓ GSI with sort key range query test passed"); +} diff --git a/crates/storage-cassandra/tests/direct/roles.rs b/crates/storage-cassandra/tests/direct/roles.rs new file mode 100644 index 00000000..5ec8d607 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/roles.rs @@ -0,0 +1,286 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for Roles management operations for `CassandraCatalogStore`. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_role_lifecycle() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Create a role + let role_name = "test-role"; + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, role_name, &trust_policy) + .await + .expect("Failed to create role"); + println!("✓ Role created"); + + // Duplicate create should fail + let result = catalog_store + .create_role(&account_id, role_name, &trust_policy) + .await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::AlreadyExists( + _ + )) + )); + println!("✓ Duplicate role rejected"); + + // List roles (should have 1) + let roles = catalog_store + .list_roles(&account_id) + .await + .expect("Failed to list roles"); + assert_eq!(roles.len(), 1); + assert_eq!(roles[0].1, role_name); + println!("✓ List roles: {} role(s)", roles.len()); + + // Get role trust policy + let retrieved_policy = catalog_store + .get_role_trust_policy(&account_id, role_name) + .await + .expect("Failed to get trust policy") + .expect("Trust policy should exist"); + assert_eq!(retrieved_policy, trust_policy); + println!("✓ Trust policy retrieved"); + + // Delete role + catalog_store + .delete_role(&account_id, role_name) + .await + .expect("Failed to delete role"); + println!("✓ Role deleted"); + + // Delete non-existent should fail + let result = catalog_store.delete_role(&account_id, role_name).await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::NotFound(_)) + )); + println!("✓ Delete non-existent role rejected"); + + // List should be empty + let roles = catalog_store + .list_roles(&account_id) + .await + .expect("Failed to list roles"); + assert_eq!(roles.len(), 0); + println!("✓ Role list empty after deletion"); + } + + #[tokio::test] + async fn test_role_tags() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Create role + let role_name = "tagged-role"; + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, role_name, &trust_policy) + .await + .expect("Failed to create role"); + + // Tag role (should fail for non-existent) + let result = catalog_store + .tag_role( + &account_id, + "nonexistent", + &[("key".to_owned(), "value".to_owned())], + ) + .await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::NotFound(_)) + )); + println!("✓ Tag non-existent role rejected"); + + // Tag role + catalog_store + .tag_role( + &account_id, + role_name, + &[ + ("Environment".to_owned(), "Production".to_owned()), + ("Team".to_owned(), "Platform".to_owned()), + ], + ) + .await + .expect("Failed to tag role"); + println!("✓ Role tagged"); + + // List tags + let tags = catalog_store + .list_role_tags(&account_id, role_name) + .await + .expect("Failed to list tags"); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0], ("Environment".to_owned(), "Production".to_owned())); + assert_eq!(tags[1], ("Team".to_owned(), "Platform".to_owned())); + println!("✓ Tags listed: {:?}", tags); + + // Update tag (upsert) + catalog_store + .tag_role( + &account_id, + role_name, + &[("Environment".to_owned(), "Staging".to_owned())], + ) + .await + .expect("Failed to update tag"); + + let tags = catalog_store + .list_role_tags(&account_id, role_name) + .await + .expect("Failed to list tags"); + assert_eq!(tags[0].1, "Staging"); + println!("✓ Tag updated"); + + // Untag role + catalog_store + .untag_role(&account_id, role_name, &["Team".to_owned()]) + .await + .expect("Failed to untag role"); + + let tags = catalog_store + .list_role_tags(&account_id, role_name) + .await + .expect("Failed to list tags"); + assert_eq!(tags.len(), 1); + println!("✓ Role untagged"); + } + + #[tokio::test] + async fn test_role_detail() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Create role + let role_name = "detailed-role"; + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "s3.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, role_name, &trust_policy) + .await + .expect("Failed to create role"); + + // Add tags + catalog_store + .tag_role( + &account_id, + role_name, + &[("Owner".to_owned(), "Engineering".to_owned())], + ) + .await + .expect("Failed to tag role"); + + // Add policy + let policy_name = "test-policy"; + let policy_doc = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "*" + }] + }); + + catalog_store + .put_policy(&account_id, "role", role_name, policy_name, &policy_doc) + .await + .expect("Failed to put policy"); + + // Get role detail + let detail = catalog_store + .get_role_detail(&account_id, role_name) + .await + .expect("Failed to get role detail") + .expect("Role detail should exist"); + + assert_eq!(detail.trust_policy, trust_policy); + assert_eq!(detail.policies.len(), 1); + assert_eq!(detail.policies[0], policy_name); + assert_eq!(detail.tags.len(), 1); + assert_eq!(detail.tags[0].0, "Owner"); + println!( + "✓ Role detail retrieved: {} policies, {} tags", + detail.policies.len(), + detail.tags.len() + ); + + // Non-existent role + let detail = catalog_store + .get_role_detail(&account_id, "nonexistent") + .await + .expect("Failed to get role detail"); + assert!(detail.is_none()); + println!("✓ Non-existent role detail returns None"); + } +} diff --git a/crates/storage-cassandra/tests/direct/scan.rs b/crates/storage-cassandra/tests/direct/scan.rs new file mode 100644 index 00000000..cb5d2a6c --- /dev/null +++ b/crates/storage-cassandra/tests/direct/scan.rs @@ -0,0 +1,782 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for the Scan operation. +//! +//! These exercise `DataEngine::scan` against the Cassandra backend directly: +//! full base-table scans (hash-only and composite key), token-based key +//! pagination, parallel-scan token-range segments, and index scans. +//! +//! Scan order in Cassandra follows the token ring (not attribute value order), +//! so pagination/segment tests assert on the *set* of returned items (coverage +//! and absence of duplicates) rather than a specific ordering. + +use extenddb_core::types::AttributeValue; +use extenddb_storage::DataEngine; +use std::collections::BTreeMap; +use std::collections::HashSet; + +use crate::helpers::{TestTable, setup_engine}; + +/// Insert `count` items into a hash-only table with ids `item-000..`. +async fn put_pk_only_items( + engine: &extenddb_storage_cassandra::CassandraEngine, + table: &TestTable, + count: usize, +) { + for i in 0..count { + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S(format!("item-{:03}", i)), + ); + item.insert("value".to_string(), AttributeValue::N(i.to_string())); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } +} + +/// Collect the `id` attribute (string) from a list of items. +fn ids(items: &[BTreeMap]) -> Vec { + items + .iter() + .map(|item| match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + other => panic!("expected string id, got {other:?}"), + }) + .collect() +} + +#[tokio::test] +async fn test_scan_empty_table() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanEmptyTable", false).await; + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan should succeed"); + + assert!(items.is_empty(), "empty table should return no items"); + assert!( + last_key.is_none(), + "empty table should have no pagination key" + ); +} + +#[tokio::test] +async fn test_scan_pk_only_all() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanPkOnlyTable", false).await; + + put_pk_only_items(&engine, &table, 10).await; + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan should succeed"); + + assert_eq!(items.len(), 10, "should return all 10 items"); + assert!( + last_key.is_none(), + "no pagination key when all items returned" + ); + + let unique: HashSet = ids(&items).into_iter().collect(); + assert_eq!(unique.len(), 10, "all returned ids should be unique"); +} + +#[tokio::test] +async fn test_scan_composite_key_all() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanCompositeTable", true).await; + + // 3 partitions, 4 sort keys each = 12 items. + for p in 0..3 { + for s in 0..4 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(format!("pk-{p}"))); + item.insert("sort".to_string(), AttributeValue::S(format!("sk-{s:02}"))); + item.insert( + "data".to_string(), + AttributeValue::N((p * 10 + s).to_string()), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + } + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan should succeed"); + + assert_eq!(items.len(), 12, "should return all 12 items"); + assert!(last_key.is_none()); + + // Every (pk, sk) pair should appear exactly once. + let pairs: HashSet<(String, String)> = items + .iter() + .map(|item| { + let id = match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected id"), + }; + let sort = match item.get("sort") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected sort"), + }; + (id, sort) + }) + .collect(); + assert_eq!(pairs.len(), 12, "all 12 (pk, sk) pairs should be present"); +} + +#[tokio::test] +async fn test_scan_limit_returns_last_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanLimitTable", false).await; + + put_pk_only_items(&engine, &table, 10).await; + + let (items, last_key) = engine + .scan(&table.key_info, Some(4), None, None, None, None) + .await + .expect("scan should succeed"); + + assert_eq!(items.len(), 4, "should return exactly the limit"); + assert!( + last_key.is_some(), + "more items remain, so a LastEvaluatedKey is expected" + ); +} + +#[tokio::test] +async fn test_scan_pagination_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanPagePkTable", false).await; + + let total = 10usize; + put_pk_only_items(&engine, &table, total).await; + + // Walk all pages with a small limit and verify full, duplicate-free coverage. + let mut seen: HashSet = HashSet::new(); + let mut start_key: Option> = None; + let mut pages = 0; + + loop { + let (items, last_key) = engine + .scan( + &table.key_info, + Some(3), + start_key.as_ref(), + None, + None, + None, + ) + .await + .expect("scan page should succeed"); + + for id in ids(&items) { + assert!(seen.insert(id.clone()), "duplicate id across pages: {id}"); + } + + pages += 1; + assert!(pages <= total + 2, "pagination did not terminate"); + + match last_key { + Some(k) => start_key = Some(k), + None => break, + } + } + + assert_eq!(seen.len(), total, "all items should be seen exactly once"); +} + +#[tokio::test] +async fn test_scan_pagination_composite_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanPageCompositeTable", true).await; + + // 4 partitions x 5 sort keys = 20 items, forcing both pagination queries + // (finish-current-partition and next-partitions). + let mut expected: HashSet<(String, String)> = HashSet::new(); + for p in 0..4 { + for s in 0..5 { + let id = format!("pk-{p}"); + let sort = format!("sk-{s:02}"); + expected.insert((id.clone(), sort.clone())); + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id)); + item.insert("sort".to_string(), AttributeValue::S(sort)); + item.insert( + "data".to_string(), + AttributeValue::N((p * 10 + s).to_string()), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + } + + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut start_key: Option> = None; + let mut pages = 0; + + loop { + let (items, last_key) = engine + .scan( + &table.key_info, + Some(3), + start_key.as_ref(), + None, + None, + None, + ) + .await + .expect("scan page should succeed"); + + for item in &items { + let id = match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected id"), + }; + let sort = match item.get("sort") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected sort"), + }; + assert!( + seen.insert((id.clone(), sort.clone())), + "duplicate (pk, sk) across pages: {id}/{sort}" + ); + } + + pages += 1; + assert!(pages <= 30, "pagination did not terminate"); + + match last_key { + Some(k) => start_key = Some(k), + None => break, + } + } + + assert_eq!(seen, expected, "all (pk, sk) pairs covered exactly once"); +} + +#[tokio::test] +async fn test_scan_parallel_segments() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanSegmentsTable", false).await; + + let total_items = 30usize; + put_pk_only_items(&engine, &table, total_items).await; + + let total_segments = 4i64; + let mut seen: HashSet = HashSet::new(); + + for segment in 0..total_segments { + let (items, last_key) = engine + .scan( + &table.key_info, + None, + None, + Some(segment), + Some(total_segments), + None, + ) + .await + .expect("segment scan should succeed"); + + // No limit was set, so each segment should be fully drained in one call. + assert!( + last_key.is_none(), + "segment {segment} should be fully drained" + ); + + for id in ids(&items) { + assert!( + seen.insert(id.clone()), + "id {id} appeared in more than one segment" + ); + } + } + + assert_eq!( + seen.len(), + total_items, + "union of all segments should cover every item exactly once" + ); +} + +#[tokio::test] +async fn test_scan_gsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::with_gsi(&engine, "ScanGsiTable", "StatusIndex", "status").await; + + // Make the GSI synchronous so writes land in the index immediately. + let catalog_keyspace = engine.catalog_keyspace(); + let update_delay = format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &update_delay, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "StatusIndex"), + ) + .await + .expect("failed to set GSI to synchronous"); + + let items_data = vec![ + ("item1", "active"), + ("item2", "pending"), + ("item3", "active"), + ("item4", "inactive"), + ]; + for (id, status) in items_data { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id.to_string())); + item.insert("status".to_string(), AttributeValue::S(status.to_string())); + item.insert("data".to_string(), AttributeValue::S(format!("data-{id}"))); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Scan the index table itself (all index entries). + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, Some("StatusIndex")) + .await + .expect("index scan should succeed"); + + assert_eq!( + items.len(), + 4, + "index scan should return all 4 projected items" + ); + assert!(last_key.is_none()); + + let unique: HashSet = ids(&items).into_iter().collect(); + assert_eq!(unique.len(), 4, "all 4 index entries should be unique"); +} + +#[tokio::test] +async fn test_scan_gsi_pagination() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::with_gsi(&engine, "ScanGsiPageTable", "StatusIndex", "status").await; + + let catalog_keyspace = engine.catalog_keyspace(); + let update_delay = format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &update_delay, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "StatusIndex"), + ) + .await + .expect("failed to set GSI to synchronous"); + + // Several distinct status values (distinct index partitions) plus repeats + // (same index partition, distinct base keys) to exercise both pagination + // queries on the index table. + let total = 12usize; + let statuses = [ + "active", "pending", "active", "inactive", "active", "pending", + ]; + for i in 0..total { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(format!("item-{i:02}"))); + item.insert( + "status".to_string(), + AttributeValue::S(statuses[i % statuses.len()].to_string()), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + let mut seen: HashSet = HashSet::new(); + let mut start_key: Option> = None; + let mut pages = 0; + + loop { + let (items, last_key) = engine + .scan( + &table.key_info, + Some(5), + start_key.as_ref(), + None, + None, + Some("StatusIndex"), + ) + .await + .expect("index scan page should succeed"); + + for id in ids(&items) { + assert!( + seen.insert(id.clone()), + "duplicate id across index pages: {id}" + ); + } + + pages += 1; + assert!(pages <= total + 2, "index pagination did not terminate"); + + // The storage layer returns an index-keys-only LastEvaluatedKey; the + // engine normally enriches it with the base table key. Mimic that here + // so the next page can resume within the correct index partition. + match last_key { + Some(mut k) => { + let last = items.last().expect("non-empty page has a last item"); + if let Some(id) = last.get("id") { + k.insert("id".to_string(), id.clone()); + } + start_key = Some(k); + } + None => break, + } + } + + assert_eq!(seen.len(), total, "all index items seen exactly once"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sort-key type coverage (N, B) and LSI scans. +// +// The base-table scan tests above use String sort keys. These exercise the +// numeric (`sk_n`) and binary (`sk_b`) clustering columns — including the +// numeric literal path used by pagination's "finish current partition" query — +// plus a scan over a Local Secondary Index table. +// ───────────────────────────────────────────────────────────────────────────── + +use extenddb_core::types::ScalarAttributeType; + +#[tokio::test] +async fn test_scan_numeric_sort_key_all() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "ScanNumSkTable", ScalarAttributeType::N).await; + + // 3 partitions x 4 numeric sort keys = 12 items. + let mut expected: HashSet<(String, String)> = HashSet::new(); + for p in 0..3 { + for s in 0..4 { + let id = format!("pk-{p}"); + let sort = (s * 100).to_string(); + expected.insert((id.clone(), sort.clone())); + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id)); + item.insert("sort".to_string(), AttributeValue::N(sort)); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + } + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan should succeed"); + + assert_eq!(items.len(), 12, "should return all 12 numeric-SK items"); + assert!(last_key.is_none()); + + let pairs: HashSet<(String, String)> = items + .iter() + .map(|item| { + let id = match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected id"), + }; + let sort = match item.get("sort") { + Some(AttributeValue::N(n)) => n.clone(), + _ => panic!("expected numeric sort"), + }; + (id, sort) + }) + .collect(); + assert_eq!(pairs, expected, "all numeric (pk, sk) pairs present"); +} + +#[tokio::test] +async fn test_scan_numeric_sort_key_pagination() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "ScanNumSkPageTable", ScalarAttributeType::N).await; + + // 4 partitions x 5 numeric sort keys = 20 items, forcing the finish-partition + // (`sk_n > `) and next-partitions queries during pagination. + let mut expected: HashSet<(String, String)> = HashSet::new(); + for p in 0..4 { + for s in 0..5 { + let id = format!("pk-{p}"); + let sort = (s * 10).to_string(); + expected.insert((id.clone(), sort.clone())); + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id)); + item.insert("sort".to_string(), AttributeValue::N(sort)); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + } + + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut start_key: Option> = None; + let mut pages = 0; + + loop { + let (items, last_key) = engine + .scan( + &table.key_info, + Some(3), + start_key.as_ref(), + None, + None, + None, + ) + .await + .expect("scan page should succeed"); + + for item in &items { + let id = match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected id"), + }; + let sort = match item.get("sort") { + Some(AttributeValue::N(n)) => n.clone(), + _ => panic!("expected numeric sort"), + }; + assert!( + seen.insert((id.clone(), sort.clone())), + "duplicate (pk, sk) across pages: {id}/{sort}" + ); + } + + pages += 1; + assert!(pages <= 30, "pagination did not terminate"); + + match last_key { + Some(k) => start_key = Some(k), + None => break, + } + } + + assert_eq!( + seen, expected, + "all numeric (pk, sk) pairs covered exactly once" + ); +} + +#[tokio::test] +async fn test_scan_binary_sort_key_all() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "ScanBinSkTable", ScalarAttributeType::B).await; + + // 2 partitions x 3 binary sort keys = 6 items. + let mut expected: HashSet<(String, Vec)> = HashSet::new(); + for p in 0..2u8 { + for s in 0..3u8 { + let id = format!("pk-{p}"); + let sort = vec![p, s, 0xAB]; + expected.insert((id.clone(), sort.clone())); + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id)); + item.insert("sort".to_string(), AttributeValue::B(sort)); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + } + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan should succeed"); + + assert_eq!(items.len(), 6, "should return all 6 binary-SK items"); + assert!(last_key.is_none()); + + let pairs: HashSet<(String, Vec)> = items + .iter() + .map(|item| { + let id = match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected id"), + }; + let sort = match item.get("sort") { + Some(AttributeValue::B(b)) => b.clone(), + _ => panic!("expected binary sort"), + }; + (id, sort) + }) + .collect(); + assert_eq!(pairs, expected, "all binary (pk, sk) pairs present"); +} + +#[tokio::test] +async fn test_scan_lsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + // Base key: (id S, sort N); LSI key: (id S, priority N). LSIs are always + // synchronous, so index rows are written on put_item. + let table = TestTable::with_lsi(&engine, "ScanLsiTable", "LsiPriority", "priority").await; + + let rows = vec![ + ("user1", 1000, 3), + ("user1", 2000, 1), + ("user2", 1500, 2), + ("user2", 2500, 5), + ]; + for (id, sort, priority) in &rows { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S((*id).to_string())); + item.insert("sort".to_string(), AttributeValue::N(sort.to_string())); + item.insert( + "priority".to_string(), + AttributeValue::N(priority.to_string()), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, Some("LsiPriority")) + .await + .expect("LSI scan should succeed"); + + assert_eq!( + items.len(), + 4, + "LSI scan should return all 4 projected items" + ); + assert!(last_key.is_none()); + + // Each item should carry the base + index key attributes. + for item in &items { + assert!(item.contains_key("id"), "projected item missing id"); + assert!( + item.contains_key("priority"), + "projected item missing priority" + ); + } +} diff --git a/crates/storage-cassandra/tests/direct/settings_store.rs b/crates/storage-cassandra/tests/direct/settings_store.rs new file mode 100644 index 00000000..7284f3ec --- /dev/null +++ b/crates/storage-cassandra/tests/direct/settings_store.rs @@ -0,0 +1,110 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for SettingsStore. +//! +//! Run with: cargo test -- --nocapture + +#[cfg(test)] +mod tests { + use crate::helpers::test_config; + use extenddb_storage::management_store::SettingsStore; + use extenddb_storage_cassandra::CassandraEngine; + + #[tokio::test] + async fn test_settings_store_direct() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + + let region = "us-east-1"; + + // Create engine directly + let engine = CassandraEngine::new(&config, region) + .await + .expect("Failed to create engine"); + + // Create catalog store directly + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + // This key is a live control-plane knob in the shared catalog, so + // remember what was there and restore it — the mutation must not + // outlive the test. + let original = catalog_store + .get_setting("control_plane_delay_seconds") + .await + .expect("get_setting failed"); + + catalog_store + .set_setting("control_plane_delay_seconds", "0.05") + .await + .expect("set_setting failed"); + + let value = catalog_store + .get_setting("control_plane_delay_seconds") + .await + .expect("get_setting after set failed") + .expect("setting missing after set"); + assert_eq!(value, "0.05"); + + // No delete_setting on the trait; when the key was absent, restore + // the value the readers default to when unset (0.25 in + // read_control_plane_delay) rather than leaving the test value. + let restore = original.unwrap_or_else(|| "0.25".to_string()); + catalog_store + .set_setting("control_plane_delay_seconds", &restore) + .await + .expect("restore setting failed"); + } + + #[tokio::test] + async fn test_list_settings() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1") + .await + .expect("Failed to create engine"); + + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + // Set a few test settings + let _ = catalog_store.set_setting("test_key_1", "value1").await; + let _ = catalog_store.set_setting("test_key_2", "value2").await; + let _ = catalog_store.set_setting("test_key_3", "value3").await; + + // List all settings + match catalog_store.list_settings().await { + Ok(settings) => { + println!( + "✓ list_settings succeeded: {} settings found", + settings.len() + ); + for (key, value) in &settings { + println!(" {} = {}", key, value); + } + + // Verify our test settings exist + assert!( + settings + .iter() + .any(|(k, v)| k == "test_key_1" && v == "value1") + ); + assert!( + settings + .iter() + .any(|(k, v)| k == "test_key_2" && v == "value2") + ); + assert!( + settings + .iter() + .any(|(k, v)| k == "test_key_3" && v == "value3") + ); + println!("✓ Verified test settings in list"); + } + Err(e) => panic!("list_settings failed: {:?}", e), + } + } +} diff --git a/crates/storage-cassandra/tests/direct/streams.rs b/crates/storage-cassandra/tests/direct/streams.rs new file mode 100644 index 00000000..865774d3 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/streams.rs @@ -0,0 +1,637 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for stream record injection into write batches. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use cdrs_tokio::types::IntoRustByName; +use extenddb_core::types::{AttributeValue, StreamViewType}; +use extenddb_storage::{DataEngine, StreamCapture}; +use extenddb_storage_cassandra::CassandraEngine; + +use crate::helpers::{TestTable, setup_engine}; + +fn capture(view_type: StreamViewType) -> StreamCapture { + StreamCapture { + view_type, + user_identity: None, + region: Arc::from("us-east-1"), + } +} + +/// Create a table with streams enabled and return its key_info + stream_label. +async fn setup_stream_table( + engine: &CassandraEngine, + table_name: &str, +) -> (extenddb_core::types::TableKeyInfo, String) { + use extenddb_core::types::{ + AttributeDefinition, CreateTableInput, KeySchemaElement, KeyType, ScalarAttributeType, + StreamSpecification, TableKeyInfo, + }; + use extenddb_storage::TableEngine; + + let account_id = crate::helpers::unique_test_account(); + crate::helpers::ensure_test_account(engine, &account_id) + .await + .unwrap(); + + let key_schema = vec![KeySchemaElement { + attribute_name: "id".to_string(), + key_type: KeyType::Hash, + }]; + let attribute_definitions = vec![AttributeDefinition { + attribute_name: "id".to_string(), + attribute_type: ScalarAttributeType::S, + }]; + + let input = CreateTableInput { + vector_indexes: None, + table_throughput_mode: None, + table_name: table_name.to_string(), + key_schema: key_schema.clone(), + attribute_definitions: attribute_definitions.clone(), + stream_specification: Some(StreamSpecification { + stream_enabled: true, + stream_view_type: Some(StreamViewType::NewAndOldImages), + }), + local_secondary_indexes: None, + global_secondary_indexes: None, + billing_mode: None, + provisioned_throughput: None, + on_demand_throughput: None, + sse_specification: None, + tags: None, + table_class: None, + deletion_protection_enabled: None, + }; + + let desc = engine.create_table(&account_id, input).await.unwrap(); + + // Fetch stream_label from catalog + let catalog_keyspace = engine.catalog_keyspace(); + let query = format!( + "SELECT stream_label FROM {catalog_keyspace}.tables WHERE account_id = ? AND table_name = ?" + ); + use cdrs_tokio::types::IntoRustByName; + let result = engine + .session_arc() + .query_with_values( + &query, + cdrs_tokio::query_values!(account_id.as_str(), table_name), + ) + .await + .unwrap(); + let stream_label: String = result + .response_body() + .unwrap() + .into_rows() + .unwrap() + .into_iter() + .next() + .unwrap() + .get_r_by_name("stream_label") + .unwrap(); + + let key_info = TableKeyInfo { + vector_indexes: Vec::new(), + table_name: table_name.to_string(), + account_id, + table_id: desc.table_id, + key_schema: key_schema.clone(), + base_key_schema: key_schema, + attribute_definitions, + has_lsi: false, + global_secondary_indexes: Vec::new(), + local_secondary_indexes: Vec::new(), + stream_specification: desc.stream_specification, + }; + + (key_info, stream_label) +} + +/// Query stream_records for a given shard and return (event_name, record_data) rows. +async fn fetch_stream_records( + engine: &CassandraEngine, + account_id: &str, + shard_id: &str, +) -> Vec<(String, String)> { + let keyspace = format!("extenddb_ttl_test_account_{}", account_id); + let query = format!( + "SELECT event_name, record_data FROM {}.stream_records WHERE shard_id = ?", + keyspace + ); + let result = engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!(shard_id)) + .await + .unwrap(); + let body = result.response_body().unwrap(); + body.into_rows() + .unwrap_or_default() + .into_iter() + .map(|row| { + let event: String = row.get_r_by_name("event_name").unwrap(); + let data: String = row.get_r_by_name("record_data").unwrap(); + (event, data) + }) + .collect() +} + +fn shard_for(pk: &str, table_id: &str) -> String { + extenddb_storage_cassandra::stream_util::assign_shard_id(pk, table_id) +} + +#[tokio::test] +async fn test_put_item_insert_writes_stream_record() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamPutInsert", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-1".to_string())); + item.insert("val".to_string(), AttributeValue::S("hello".to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewAndOldImages)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-1", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + + assert_eq!(records.len(), 1); + assert_eq!(records[0].0, "Insert"); + let data: serde_json::Value = serde_json::from_str(&records[0].1).unwrap(); + assert_eq!(data["eventName"], "INSERT"); + assert!(data["dynamodb"]["NewImage"].is_object()); + assert!(data["dynamodb"].get("OldImage").is_none()); +} + +#[tokio::test] +async fn test_put_item_overwrite_writes_modify_stream_record() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamPutModify", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-2".to_string())); + item.insert("val".to_string(), AttributeValue::S("v1".to_string())); + + // First write — no stream capture + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + // Second write — with stream capture + item.insert("val".to_string(), AttributeValue::S("v2".to_string())); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewAndOldImages)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-2", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + + assert_eq!(records.len(), 1); + assert_eq!(records[0].0, "Modify"); + let data: serde_json::Value = serde_json::from_str(&records[0].1).unwrap(); + assert_eq!(data["eventName"], "MODIFY"); + assert!(data["dynamodb"]["OldImage"].is_object()); + assert!(data["dynamodb"]["NewImage"].is_object()); +} + +#[tokio::test] +async fn test_delete_item_writes_remove_stream_record() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamDelete", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-3".to_string())); + item.insert( + "val".to_string(), + AttributeValue::S("to-delete".to_string()), + ); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("pk-3".to_string())); + + engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::OldImage)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-3", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + + assert_eq!(records.len(), 1); + assert_eq!(records[0].0, "Remove"); + let data: serde_json::Value = serde_json::from_str(&records[0].1).unwrap(); + assert_eq!(data["eventName"], "REMOVE"); + assert!(data["dynamodb"]["OldImage"].is_object()); +} + +#[tokio::test] +async fn test_delete_nonexistent_item_writes_no_stream_record() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamDeleteMissing", false).await; + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("pk-ghost".to_string())); + + engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::OldImage)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-ghost", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + assert!(records.is_empty()); +} + +#[tokio::test] +async fn test_keys_only_view_type_omits_images() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamKeysOnly", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-keys".to_string())); + item.insert("val".to_string(), AttributeValue::S("secret".to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::KeysOnly)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-keys", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + + assert_eq!(records.len(), 1); + let data: serde_json::Value = serde_json::from_str(&records[0].1).unwrap(); + assert!(data["dynamodb"].get("NewImage").is_none()); + assert!(data["dynamodb"].get("OldImage").is_none()); + assert!(data["dynamodb"]["Keys"].is_object()); +} + +#[tokio::test] +async fn test_same_pk_records_land_in_same_shard_with_ordered_sequences() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamShardConsistency", false).await; + + for i in 0..3u32 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("same-pk".to_string())); + item.insert("val".to_string(), AttributeValue::N(i.to_string())); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewImage)), + ) + .await + .unwrap(); + } + + let shard_id = shard_for("same-pk", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + assert_eq!(records.len(), 3); + + let seqs: Vec = records + .iter() + .map(|(_, data)| { + let v: serde_json::Value = serde_json::from_str(data).unwrap(); + v["dynamodb"]["SequenceNumber"] + .as_str() + .unwrap() + .to_string() + }) + .collect(); + let sorted = { + let mut s = seqs.clone(); + s.sort(); + s + }; + assert_eq!(seqs, sorted, "sequence numbers must be in ascending order"); +} + +// --- Read-side tests --- + +#[tokio::test] +async fn test_validate_shard_ok() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, stream_label) = setup_stream_table(&engine, "ValidateShardOk").await; + let arn = extenddb_storage::util::stream_arn( + "us-east-1", + &key_info.account_id, + &key_info.table_name, + &stream_label, + ); + let shard_id = shard_for("any-pk", &key_info.table_id); + + engine + .validate_shard(&key_info.account_id, &arn, &shard_id) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_validate_shard_wrong_arn_fails() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let (key_info, _) = setup_stream_table(&engine, "ValidateShardBadArn").await; + let bad_arn = extenddb_storage::util::stream_arn( + "us-east-1", + &key_info.account_id, + &key_info.table_name, + "1970-01-01T00:00:00", + ); + let shard_id = shard_for("any-pk", &key_info.table_id); + + let err = engine + .validate_shard(&key_info.account_id, &bad_arn, &shard_id) + .await + .unwrap_err(); + assert!(matches!(err, StorageError::TableNotFound(_))); +} + +#[tokio::test] +async fn test_latest_sequence_number_empty_shard() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, _) = setup_stream_table(&engine, "LatestSeqEmpty").await; + let shard_id = shard_for("any-pk", &key_info.table_id); + + let seq = engine.latest_sequence_number(&shard_id).await.unwrap(); + assert!(seq.is_none()); +} + +#[tokio::test] +async fn test_latest_sequence_number_after_write() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, _) = setup_stream_table(&engine, "LatestSeqAfterWrite").await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-seq".to_string())); + engine + .put_item( + &key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewImage)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-seq", &key_info.table_id); + let seq = engine.latest_sequence_number(&shard_id).await.unwrap(); + assert!(seq.is_some()); + assert_eq!(seq.unwrap().len(), 23); +} + +#[tokio::test] +async fn test_get_stream_records_returns_written_records() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, _) = setup_stream_table(&engine, "GetStreamRecords").await; + + for i in 0..3u32 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-read".to_string())); + item.insert("val".to_string(), AttributeValue::N(i.to_string())); + engine + .put_item( + &key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewImage)), + ) + .await + .unwrap(); + } + + let shard_id = shard_for("pk-read", &key_info.table_id); + let (records, last_seq) = engine + .get_stream_records(&key_info.account_id, &shard_id, None, 10) + .await + .unwrap(); + + assert_eq!(records.len(), 3); + assert!(last_seq.is_some()); + // All records are for the same PK + for r in &records { + assert!(r.dynamodb.keys.contains_key("id")); + } +} + +#[tokio::test] +async fn test_get_stream_records_after_sequence_paginates() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, _) = setup_stream_table(&engine, "GetStreamRecordsPaginate").await; + + for i in 0..4u32 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-page".to_string())); + item.insert("val".to_string(), AttributeValue::N(i.to_string())); + engine + .put_item( + &key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewImage)), + ) + .await + .unwrap(); + } + + let shard_id = shard_for("pk-page", &key_info.table_id); + let (first_page, last_seq) = engine + .get_stream_records(&key_info.account_id, &shard_id, None, 2) + .await + .unwrap(); + assert_eq!(first_page.len(), 2); + + let (second_page, _) = engine + .get_stream_records(&key_info.account_id, &shard_id, last_seq.as_deref(), 10) + .await + .unwrap(); + assert_eq!(second_page.len(), 2); + + // No overlap + let first_seqs: Vec<_> = first_page + .iter() + .map(|r| &r.dynamodb.sequence_number) + .collect(); + let second_seqs: Vec<_> = second_page + .iter() + .map(|r| &r.dynamodb.sequence_number) + .collect(); + assert!(first_seqs.iter().all(|s| !second_seqs.contains(s))); +} + +#[tokio::test] +async fn test_describe_stream_returns_shards() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_core::types::DescribeStreamInput; + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, stream_label) = setup_stream_table(&engine, "DescribeStream").await; + let arn = extenddb_storage::util::stream_arn( + "us-east-1", + &key_info.account_id, + &key_info.table_name, + &stream_label, + ); + + let desc = engine + .describe_stream( + &key_info.account_id, + &DescribeStreamInput { + stream_arn: arn.clone(), + limit: None, + exclusive_start_shard_id: None, + }, + ) + .await + .unwrap(); + + assert_eq!(desc.stream_arn, arn); + assert_eq!(desc.table_name, key_info.table_name); + assert_eq!(desc.shards.len(), 4); // SHARDS_PER_STREAM + assert!(desc.last_evaluated_shard_id.is_none()); +} + +#[tokio::test] +async fn test_list_streams_includes_stream_enabled_table() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, stream_label) = setup_stream_table(&engine, "ListStreams").await; + let expected_arn = extenddb_storage::util::stream_arn( + "us-east-1", + &key_info.account_id, + &key_info.table_name, + &stream_label, + ); + + let (streams, _) = engine + .list_streams(&key_info.account_id, None, 100, None) + .await + .unwrap(); + + assert!(streams.iter().any(|s| s.stream_arn == expected_arn)); +} diff --git a/crates/storage-cassandra/tests/direct/table_engine.rs b/crates/storage-cassandra/tests/direct/table_engine.rs new file mode 100644 index 00000000..1005f113 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/table_engine.rs @@ -0,0 +1,152 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for TableEngine. +//! +//! Run with: cargo test -- --nocapture + +#[cfg(test)] +mod tests { + use extenddb_storage_cassandra::CassandraEngine; + + /// Tests table lifecycle operations. + /// + /// The test automatically provisions a test account and adjusts replication factors + /// for single-node testing. No manual setup required beyond running Cassandra and + /// `extenddb init --backend cassandra --keyspace-prefix extenddb_test`. + #[tokio::test] + async fn test_table_lifecycle() { + if crate::helpers::skip_without_cassandra() { + return; + } + use crate::helpers::test_config; + use extenddb_core::types::{ + AttributeDefinition, BillingMode, CreateTableInput, DeleteTableInput, + DescribeTableInput, KeySchemaElement, KeyType, ListTablesInput, ScalarAttributeType, + }; + use extenddb_storage::TableEngine; + + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1") + .await + .expect("Failed to create engine"); + + let account_id = crate::helpers::test_account_id(&engine) + .await + .expect("Failed to get test account"); + let table_name = "DirectTestTable"; + + println!("=== Testing Table Lifecycle ==="); + + // Test create_table + let create_input = CreateTableInput { + vector_indexes: None, + table_throughput_mode: None, + table_name: table_name.to_string(), + key_schema: vec![KeySchemaElement { + attribute_name: "pk".to_string(), + key_type: KeyType::Hash, + }], + attribute_definitions: vec![AttributeDefinition { + attribute_name: "pk".to_string(), + attribute_type: ScalarAttributeType::S, + }], + billing_mode: Some(BillingMode::PayPerRequest), + global_secondary_indexes: None, + local_secondary_indexes: None, + provisioned_throughput: None, + on_demand_throughput: None, + sse_specification: None, + stream_specification: None, + tags: None, + deletion_protection_enabled: None, + table_class: None, + }; + + engine + .create_table(&account_id, create_input) + .await + .expect("create_table failed"); + + // Test describe_table + let describe_input = DescribeTableInput { + table_name: table_name.to_string(), + }; + let desc = engine + .describe_table(&account_id, describe_input) + .await + .expect("describe_table failed"); + assert_eq!(desc.table_name, table_name); + + // Test list_tables + let list_input = ListTablesInput { + exclusive_start_table_name: None, + limit: None, + }; + let listed = engine + .list_tables(&account_id, list_input) + .await + .expect("list_tables failed"); + assert!( + listed.table_names.iter().any(|t| t == table_name), + "created table missing from list_tables" + ); + + // Test delete_table + let delete_input = DeleteTableInput { + table_name: table_name.to_string(), + }; + engine + .delete_table(&account_id, delete_input) + .await + .expect("delete_table failed"); + + // Verify deletion + let describe_input = DescribeTableInput { + table_name: table_name.to_string(), + }; + assert!( + engine + .describe_table(&account_id, describe_input) + .await + .is_err(), + "table still describable after deletion" + ); + } + + /// Tests index_info_by_table_id against a table with a GSI. + /// + /// Uses the table_id path (the engine's hot path for index Query/Scan + /// routing) because it doesn't require the table to be ACTIVE — in these + /// direct tests there is no control-plane worker to transition the table + /// out of CREATING, which index_info()-by-name would require via + /// fetch_table_key_info. The by-name wrapper shares this logic and is + /// covered by the Python integration suite (where tables are ACTIVE). + #[tokio::test] + async fn test_index_info() { + if crate::helpers::skip_without_cassandra() { + return; + } + use crate::helpers::{TestTable, setup_engine}; + use extenddb_core::types::IndexType; + use extenddb_storage::TableEngine; + + let engine = setup_engine().await; + let table = TestTable::with_gsi(&engine, "IndexInfoTable", "StatusIndex", "status").await; + + // Known index resolves to its metadata. + let info = engine + .index_info_by_table_id(&table.key_info.table_id, "StatusIndex") + .await + .expect("index_info_by_table_id should succeed"); + assert_eq!(info.index_name, "StatusIndex"); + assert!(matches!(info.index_type, IndexType::Gsi)); + assert_eq!(info.key_schema[0].attribute_name, "status"); + + // Unknown index returns an error (not the old "not yet implemented" stub). + let missing = engine + .index_info_by_table_id(&table.key_info.table_id, "NoSuchIndex") + .await; + assert!(missing.is_err(), "unknown index should return an error"); + } +} diff --git a/crates/storage-cassandra/tests/direct/transact_get_items.rs b/crates/storage-cassandra/tests/direct/transact_get_items.rs new file mode 100644 index 00000000..77ea0dd0 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/transact_get_items.rs @@ -0,0 +1,448 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for TransactGetItems. + +use extenddb_core::types::AttributeValue; +use extenddb_storage::error::StorageError; +use extenddb_storage::{DataEngine, TransactGetOp}; +use std::collections::BTreeMap; + +use crate::helpers::{TestTable, setup_engine}; + +#[tokio::test] +async fn test_transact_get_items_single_item() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetSingleTable", false).await; + + // Put an item + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("item-1".to_string())); + item.insert( + "name".to_string(), + AttributeValue::S("Test Item".to_string()), + ); + item.insert("count".to_string(), AttributeValue::N("42".to_string())); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put item failed"); + + // TransactGetItems with single item + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("item-1".to_string())); + + let ops = vec![TransactGetOp { + key_info: &table.key_info, + key: &key, + }]; + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems failed"); + + assert_eq!(results.len(), 1); + let retrieved = results[0].as_ref().expect("Item should exist"); + assert_eq!(retrieved.get("id"), item.get("id")); + assert_eq!(retrieved.get("name"), item.get("name")); + assert_eq!(retrieved.get("count"), item.get("count")); +} + +#[tokio::test] +async fn test_transact_get_items_multiple_items() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetMultiTable", false).await; + + // Put multiple items + for i in 1..=5 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(format!("item-{}", i))); + item.insert("value".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put item failed"); + } + + // TransactGetItems with multiple items + let ops: Vec = (1..=5) + .map(|i| { + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S(format!("item-{}", i))); + TransactGetOp { + key_info: &table.key_info, + key: Box::leak(Box::new(key)), + } + }) + .collect(); + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems failed"); + + assert_eq!(results.len(), 5); + for (i, result) in results.iter().enumerate() { + let item = result.as_ref().expect("Item should exist"); + let expected_id = format!("item-{}", i + 1); + assert_eq!( + item.get("id"), + Some(&AttributeValue::S(expected_id.clone())) + ); + assert_eq!( + item.get("value"), + Some(&AttributeValue::N((i + 1).to_string())) + ); + } +} + +#[tokio::test] +async fn test_transact_get_items_nonexistent_item() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetNonexistentTable", false).await; + + // Put one item + let mut item1 = BTreeMap::new(); + item1.insert("id".to_string(), AttributeValue::S("exists".to_string())); + item1.insert( + "data".to_string(), + AttributeValue::S("some data".to_string()), + ); + + engine + .put_item( + &table.key_info, + item1, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + // TransactGetItems with mix of existent and non-existent + let mut key1 = BTreeMap::new(); + key1.insert("id".to_string(), AttributeValue::S("exists".to_string())); + + let mut key2 = BTreeMap::new(); + key2.insert( + "id".to_string(), + AttributeValue::S("does-not-exist".to_string()), + ); + + let ops = vec![ + TransactGetOp { + key_info: &table.key_info, + key: &key1, + }, + TransactGetOp { + key_info: &table.key_info, + key: &key2, + }, + ]; + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems failed"); + + assert_eq!(results.len(), 2); + assert!(results[0].is_some(), "First item should exist"); + assert!(results[1].is_none(), "Second item should not exist"); +} + +#[tokio::test] +async fn test_transact_get_items_with_sort_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetWithSKTable", true).await; + + // Put items with sort keys + for i in 1..=3 { + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("partition-1".to_string()), + ); + item.insert("sort".to_string(), AttributeValue::S(format!("sort-{}", i))); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + } + + // TransactGetItems with composite keys + let ops: Vec = (1..=3) + .map(|i| { + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("partition-1".to_string()), + ); + key.insert("sort".to_string(), AttributeValue::S(format!("sort-{}", i))); + TransactGetOp { + key_info: &table.key_info, + key: Box::leak(Box::new(key)), + } + }) + .collect(); + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems failed"); + + assert_eq!(results.len(), 3); + for (i, result) in results.iter().enumerate() { + let item = result.as_ref().expect("Item should exist"); + assert_eq!( + item.get("sort"), + Some(&AttributeValue::S(format!("sort-{}", i + 1))) + ); + assert_eq!( + item.get("data"), + Some(&AttributeValue::N((i + 1).to_string())) + ); + } +} + +#[tokio::test] +async fn test_transact_get_items_cross_table() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + + // Create both tables in the same account + let account_id = crate::helpers::unique_test_account(); + let table1 = + TestTable::with_account(&engine, &account_id, "TransactGetCrossTable1", false).await; + let table2 = + TestTable::with_account(&engine, &account_id, "TransactGetCrossTable2", false).await; + + // Put items in different tables + let mut item1 = BTreeMap::new(); + item1.insert( + "id".to_string(), + AttributeValue::S("table1-item".to_string()), + ); + item1.insert( + "source".to_string(), + AttributeValue::S("table1".to_string()), + ); + + let mut item2 = BTreeMap::new(); + item2.insert( + "id".to_string(), + AttributeValue::S("table2-item".to_string()), + ); + item2.insert( + "source".to_string(), + AttributeValue::S("table2".to_string()), + ); + + engine + .put_item( + &table1.key_info, + item1, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + engine + .put_item( + &table2.key_info, + item2, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + // TransactGetItems across tables + let mut key1 = BTreeMap::new(); + key1.insert( + "id".to_string(), + AttributeValue::S("table1-item".to_string()), + ); + + let mut key2 = BTreeMap::new(); + key2.insert( + "id".to_string(), + AttributeValue::S("table2-item".to_string()), + ); + + let ops = vec![ + TransactGetOp { + key_info: &table1.key_info, + key: &key1, + }, + TransactGetOp { + key_info: &table2.key_info, + key: &key2, + }, + ]; + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems should succeed across tables in same account"); + + assert_eq!(results.len(), 2); + assert_eq!( + results[0].as_ref().unwrap().get("source"), + Some(&AttributeValue::S("table1".to_string())) + ); + assert_eq!( + results[1].as_ref().unwrap().get("source"), + Some(&AttributeValue::S("table2".to_string())) + ); +} + +#[tokio::test] +async fn test_transact_get_items_validation_error() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetValidationTable", true).await; + + // Create a key with wrong type (should have both id and sort) + let mut bad_key = BTreeMap::new(); + bad_key.insert("id".to_string(), AttributeValue::S("test".to_string())); + // Missing sort - validation should fail + + let ops = vec![TransactGetOp { + key_info: &table.key_info, + key: &bad_key, + }]; + + let result = engine.transact_get_items(&ops).await; + + // Should get TransactionCanceled with validation error + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons.len(), 1); + assert_eq!(reasons[0].code, "ValidationError"); + } + _ => panic!("Expected TransactionCanceled with ValidationError"), + } +} + +#[tokio::test] +async fn test_transact_get_items_max_items() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetMaxItemsTable", false).await; + + // Put 100 items (DynamoDB limit) + for i in 1..=100 { + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S(format!("item-{:03}", i)), + ); + item.insert("index".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + } + + // TransactGetItems with 100 items (max allowed) + let ops: Vec = (1..=100) + .map(|i| { + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S(format!("item-{:03}", i)), + ); + TransactGetOp { + key_info: &table.key_info, + key: Box::leak(Box::new(key)), + } + }) + .collect(); + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems with 100 items should succeed"); + + assert_eq!(results.len(), 100); + for result in &results { + assert!(result.is_some(), "All items should exist"); + } +} + +#[tokio::test] +async fn test_transact_get_items_empty() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + + // TransactGetItems with empty ops list + let ops: Vec = vec![]; + + let results = engine + .transact_get_items(&ops) + .await + .expect("Empty TransactGetItems should succeed"); + + assert_eq!(results.len(), 0); +} diff --git a/crates/storage-cassandra/tests/direct/transact_write_items.rs b/crates/storage-cassandra/tests/direct/transact_write_items.rs new file mode 100644 index 00000000..1e1a8b12 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/transact_write_items.rs @@ -0,0 +1,585 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for TransactWriteItems (two-phase commit). + +use extenddb_core::expression::ExpressionMaps; +use extenddb_core::types::{AttributeValue, Item, ReturnValuesOnConditionCheckFailure}; +use extenddb_storage::{DataEngine, IdempotencyKey, TransactWriteOp}; + +use crate::helpers::{TestTable, setup_engine}; + +#[tokio::test] +async fn test_transact_write_put_simple() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnPutSimple", false).await; + + // Create an item to put + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S("test_key".to_string())); + item.insert("value".to_string(), AttributeValue::N("42".to_string())); + + let maps = ExpressionMaps::default(); + + // Create transaction with single Put operation + let ops = vec![TransactWriteOp::Put { + key_info: &table.key_info, + item: &item, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + + let result = engine.transact_write_items(&ops, None).await; + + // Should succeed + assert!( + result.is_ok(), + "Transaction should succeed: {:?}", + result.err() + ); + + // Verify item was written + let mut key = Item::new(); + key.insert("id".to_string(), AttributeValue::S("test_key".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item should succeed"); + + assert!(retrieved.is_some(), "Item should exist after commit"); + let retrieved_item = retrieved.unwrap(); + assert_eq!( + retrieved_item.get("value"), + Some(&AttributeValue::N("42".to_string())), + "Item value should match" + ); +} + +#[tokio::test] +async fn test_transact_write_put_and_delete() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnPutDel", false).await; + + let maps = ExpressionMaps::default(); + + // First, put an item using regular put_item + let mut item1 = Item::new(); + item1.insert("id".to_string(), AttributeValue::S("key1".to_string())); + item1.insert( + "value".to_string(), + AttributeValue::S("original".to_string()), + ); + + engine + .put_item(&table.key_info, item1.clone(), false, None, &maps, None) + .await + .expect("Initial put_item should succeed"); + + // Now create a transaction that: + // 1. Puts a new item (key2) + // 2. Deletes the existing item (key1) + let mut item2 = Item::new(); + item2.insert("id".to_string(), AttributeValue::S("key2".to_string())); + item2.insert("value".to_string(), AttributeValue::S("new".to_string())); + + let mut key1 = Item::new(); + key1.insert("id".to_string(), AttributeValue::S("key1".to_string())); + + let ops = vec![ + TransactWriteOp::Put { + key_info: &table.key_info, + item: &item2, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }, + TransactWriteOp::Delete { + key_info: &table.key_info, + key: &key1, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }, + ]; + + let result = engine.transact_write_items(&ops, None).await; + assert!( + result.is_ok(), + "Transaction should succeed: {:?}", + result.err() + ); + + // Verify key1 was deleted + let retrieved1 = engine + .get_item(&table.key_info, &key1) + .await + .expect("get_item should succeed"); + assert!(retrieved1.is_none(), "key1 should be deleted"); + + // Verify key2 was written + let mut key2 = Item::new(); + key2.insert("id".to_string(), AttributeValue::S("key2".to_string())); + + let retrieved2 = engine + .get_item(&table.key_info, &key2) + .await + .expect("get_item should succeed"); + assert!(retrieved2.is_some(), "key2 should exist"); + assert_eq!( + retrieved2.unwrap().get("value"), + Some(&AttributeValue::S("new".to_string())), + "key2 value should match" + ); +} + +#[tokio::test] +async fn test_transact_write_rollback_on_condition_failure() { + if crate::helpers::skip_without_cassandra() { + return; + } + use std::collections::HashMap; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnRollback", false).await; + + // First, put an item with value=10 + let mut item1 = Item::new(); + item1.insert("id".to_string(), AttributeValue::S("key1".to_string())); + item1.insert("value".to_string(), AttributeValue::N("10".to_string())); + + let maps = ExpressionMaps::default(); + + engine + .put_item(&table.key_info, item1.clone(), false, None, &maps, None) + .await + .expect("Initial put_item should succeed"); + + // Create a transaction with a condition that will fail + // Condition: value = 999 (which is false, so transaction will rollback) + use extenddb_core::expression::{CompareOp, Expr, PathElement}; + + // Create expression maps with the comparison value + let mut values_map = HashMap::new(); + values_map.insert("val1".to_string(), AttributeValue::N("999".to_string())); + let maps_with_condition = ExpressionMaps::new(HashMap::new(), values_map); + + let condition = Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute( + "value".to_string(), + )])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder("val1".to_string())), + }; + + // Put a new item (key2) + let mut item2 = Item::new(); + item2.insert("id".to_string(), AttributeValue::S("key2".to_string())); + item2.insert("value".to_string(), AttributeValue::S("new".to_string())); + + let ops = vec![ + TransactWriteOp::Put { + key_info: &table.key_info, + item: &item2, + condition: None, + maps: &maps_with_condition, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }, + TransactWriteOp::ConditionCheck { + key_info: &table.key_info, + key: &item1, + condition: &condition, + maps: &maps_with_condition, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + }, + ]; + + // Execute the transaction - should fail with TransactionCanceled + let result = engine.transact_write_items(&ops, None).await; + assert!( + result.is_err(), + "Transaction should fail due to condition check" + ); + + // Verify key2 was NOT written (transaction was rolled back) + let mut key2 = Item::new(); + key2.insert("id".to_string(), AttributeValue::S("key2".to_string())); + + let retrieved2 = engine + .get_item(&table.key_info, &key2) + .await + .expect("get_item should succeed"); + assert!( + retrieved2.is_none(), + "key2 should not exist (transaction rolled back)" + ); + + // Verify key1 still exists unchanged + let mut key1 = Item::new(); + key1.insert("id".to_string(), AttributeValue::S("key1".to_string())); + + let retrieved1 = engine + .get_item(&table.key_info, &key1) + .await + .expect("get_item should succeed"); + assert!(retrieved1.is_some(), "key1 should still exist"); + assert_eq!( + retrieved1.unwrap().get("value"), + Some(&AttributeValue::N("10".to_string())), + "key1 should be unchanged" + ); +} + +#[tokio::test] +async fn test_transact_write_rollback_update_existing_item() { + if crate::helpers::skip_without_cassandra() { + return; + } + // Rollback must restore an existing item's prepared_txn_id to null without + // deleting the row (created_to_prepare=false path). + use extenddb_core::expression::{CompareOp, Expr, PathElement, UpdateAction}; + use std::collections::HashMap; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnRollbackUpdate", false).await; + + // Write an existing item + let mut existing = Item::new(); + existing.insert("id".to_string(), AttributeValue::S("upd_key".to_string())); + existing.insert("value".to_string(), AttributeValue::N("100".to_string())); + let maps_empty = ExpressionMaps::default(); + engine + .put_item( + &table.key_info, + existing.clone(), + false, + None, + &maps_empty, + None, + ) + .await + .expect("setup put_item"); + + let mut key = Item::new(); + key.insert("id".to_string(), AttributeValue::S("upd_key".to_string())); + + // Update sets value = :new_val (999) + let mut values_map = HashMap::new(); + values_map.insert("new_val".to_string(), AttributeValue::N("999".to_string())); + // ConditionCheck on a non-existent item forces rollback + values_map.insert("v".to_string(), AttributeValue::S("x".to_string())); + let maps_with_vals = ExpressionMaps::new(HashMap::new(), values_map); + + let set_action = UpdateAction::Set { + path: vec![PathElement::Attribute("value".to_string())], + value: Expr::Placeholder("new_val".to_string()), + }; + + let mut other_key = Item::new(); + other_key.insert( + "id".to_string(), + AttributeValue::S("no_such_key".to_string()), + ); + let failing_condition = Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute( + "value".to_string(), + )])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder("v".to_string())), + }; + + let actions = [set_action]; + let ops = vec![ + TransactWriteOp::Update { + key_info: &table.key_info, + key: &key, + actions: &actions, + condition: None, + maps: &maps_with_vals, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }, + TransactWriteOp::ConditionCheck { + key_info: &table.key_info, + key: &other_key, + condition: &failing_condition, + maps: &maps_with_vals, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + }, + ]; + + let result = engine.transact_write_items(&ops, None).await; + assert!(result.is_err(), "Transaction should be cancelled"); + + // Item must still exist with original value and be unlocked + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item"); + assert!(retrieved.is_some(), "item must still exist after rollback"); + assert_eq!( + retrieved.unwrap().get("value"), + Some(&AttributeValue::N("100".to_string())), + "item value must be unchanged after rollback" + ); +} + +#[tokio::test] +async fn test_transact_write_rollback_delete_existing_item() { + if crate::helpers::skip_without_cassandra() { + return; + } + // Rollback of a Delete must leave the existing item intact and unlocked. + use extenddb_core::expression::{CompareOp, Expr, PathElement}; + use std::collections::HashMap; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnRollbackDelete", false).await; + let maps = ExpressionMaps::default(); + + // Write an existing item + let mut existing = Item::new(); + existing.insert("id".to_string(), AttributeValue::S("del_key".to_string())); + existing.insert( + "value".to_string(), + AttributeValue::S("keep_me".to_string()), + ); + engine + .put_item(&table.key_info, existing.clone(), false, None, &maps, None) + .await + .expect("setup put_item"); + + let mut key = Item::new(); + key.insert("id".to_string(), AttributeValue::S("del_key".to_string())); + + // ConditionCheck on a non-existent item - will fail, forcing rollback + let mut other_key = Item::new(); + other_key.insert( + "id".to_string(), + AttributeValue::S("no_such_key".to_string()), + ); + + let mut values_map = HashMap::new(); + values_map.insert("v".to_string(), AttributeValue::S("x".to_string())); + let failing_maps = ExpressionMaps::new(HashMap::new(), values_map); + + let failing_condition = Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute( + "value".to_string(), + )])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder("v".to_string())), + }; + + let ops = vec![ + TransactWriteOp::Delete { + key_info: &table.key_info, + key: &key, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }, + TransactWriteOp::ConditionCheck { + key_info: &table.key_info, + key: &other_key, + condition: &failing_condition, + maps: &failing_maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + }, + ]; + + let result = engine.transact_write_items(&ops, None).await; + assert!(result.is_err(), "Transaction should be cancelled"); + + // Item must still exist and be readable (not locked) + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item"); + assert!(retrieved.is_some(), "item must still exist after rollback"); + assert_eq!( + retrieved.unwrap().get("value"), + Some(&AttributeValue::S("keep_me".to_string())), + "item value must be unchanged after rollback" + ); + + // Verify item is no longer locked (a subsequent write should succeed) + engine + .put_item(&table.key_info, existing.clone(), false, None, &maps, None) + .await + .expect("put_item after rollback should succeed (item not locked)"); +} + +#[tokio::test] +async fn test_idempotency_token_replay() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnIdempotencyReplay", false).await; + let token = format!("tok-replay-{}", uuid::Uuid::new_v4().simple()); + + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S("idem_key".to_string())); + let maps = ExpressionMaps::default(); + let ops = vec![TransactWriteOp::Put { + key_info: &table.key_info, + item: &item, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + + engine + .transact_write_items( + &ops, + Some(IdempotencyKey { + account_id: &table.key_info.account_id, + token: &token, + fingerprint: "fp-123", + }), + ) + .await + .expect("First call should succeed"); + + let result = engine + .transact_write_items( + &ops, + Some(IdempotencyKey { + account_id: &table.key_info.account_id, + token: &token, + fingerprint: "fp-123", + }), + ) + .await; + assert!( + matches!( + result, + Err(extenddb_storage::error::StorageError::IdempotentReplay) + ), + "Expected IdempotentReplay, got: {:?}", + result + ); +} + +#[tokio::test] +async fn test_idempotency_token_mismatch() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnIdempotencyMismatch", false).await; + let token = format!("tok-mismatch-{}", uuid::Uuid::new_v4().simple()); + + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S("idem_key2".to_string())); + let maps = ExpressionMaps::default(); + let ops = vec![TransactWriteOp::Put { + key_info: &table.key_info, + item: &item, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + + engine + .transact_write_items( + &ops, + Some(IdempotencyKey { + account_id: &table.key_info.account_id, + token: &token, + fingerprint: "fp-aaa", + }), + ) + .await + .expect("First call should succeed"); + + let result = engine + .transact_write_items( + &ops, + Some(IdempotencyKey { + account_id: &table.key_info.account_id, + token: &token, + fingerprint: "fp-bbb", + }), + ) + .await; + assert!( + matches!( + result, + Err(extenddb_storage::error::StorageError::IdempotentMismatch) + ), + "Expected IdempotentMismatch, got: {:?}", + result + ); +} + +#[tokio::test] +async fn test_idempotency_token_is_scoped_to_account() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table_a = TestTable::new(&engine, "TxnIdempotencyAccountA", false).await; + let table_b = TestTable::new(&engine, "TxnIdempotencyAccountB", false).await; + let token = format!("shared-token-{}", uuid::Uuid::new_v4().simple()); + let maps = ExpressionMaps::default(); + + let mut item_a = Item::new(); + item_a.insert("id".to_owned(), AttributeValue::S("account-a".to_owned())); + let ops_a = vec![TransactWriteOp::Put { + key_info: &table_a.key_info, + item: &item_a, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + engine + .transact_write_items( + &ops_a, + Some(IdempotencyKey { + account_id: &table_a.key_info.account_id, + token: &token, + fingerprint: "same-fingerprint", + }), + ) + .await + .expect("first account should reserve the token"); + + let mut item_b = Item::new(); + item_b.insert("id".to_owned(), AttributeValue::S("account-b".to_owned())); + let ops_b = vec![TransactWriteOp::Put { + key_info: &table_b.key_info, + item: &item_b, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + engine + .transact_write_items( + &ops_b, + Some(IdempotencyKey { + account_id: &table_b.key_info.account_id, + token: &token, + fingerprint: "same-fingerprint", + }), + ) + .await + .expect("the same token in another account must not replay"); +} diff --git a/crates/storage-cassandra/tests/direct/transaction_ledger.rs b/crates/storage-cassandra/tests/direct/transaction_ledger.rs new file mode 100644 index 00000000..ab4d9732 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/transaction_ledger.rs @@ -0,0 +1,296 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for transaction ledger operations. + +use extenddb_storage::error::StorageError; +use extenddb_storage_cassandra::data::transaction_ledger::TransactionState; +use uuid::Uuid; + +use crate::helpers::{ensure_test_account, setup_engine, unique_test_account}; + +#[tokio::test] +async fn test_write_and_read_ledger_entry() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let txn_id = Uuid::new_v4(); + let started_at = 1000000; + let items_blob = r#"[{"table":"t1","pk":"a","sk":"b"}]"#; + + // Write ledger entry + engine + .write_ledger_entry( + &keyspace, + txn_id, + TransactionState::Preparing, + started_at, + Some("client-token-1"), + Some("fingerprint-1"), + items_blob, + ) + .await + .expect("Write ledger entry failed"); + + // Read it back + let entry = engine + .read_ledger_entry(&keyspace, txn_id) + .await + .expect("Read ledger entry failed") + .expect("Entry should exist"); + + assert_eq!(entry.txn_id, txn_id); + assert_eq!(entry.state, "PREPARING"); + assert_eq!(entry.started_at, started_at); + assert_eq!(entry.client_token, Some("client-token-1".to_string())); + assert_eq!(entry.request_fingerprint, Some("fingerprint-1".to_string())); + assert_eq!(entry.items_blob, items_blob); +} + +#[tokio::test] +async fn test_write_duplicate_txn_id_fails() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let txn_id = Uuid::new_v4(); + let items_blob = r#"[{"table":"t1"}]"#; + + // First write succeeds + engine + .write_ledger_entry( + &keyspace, + txn_id, + TransactionState::Preparing, + 1000000, + None, + None, + items_blob, + ) + .await + .expect("First write should succeed"); + + // Second write with same txn_id fails + let result = engine + .write_ledger_entry( + &keyspace, + txn_id, + TransactionState::Preparing, + 2000000, + None, + None, + items_blob, + ) + .await; + + assert!(result.is_err(), "Duplicate txn_id should fail"); + match result { + Err(StorageError::Internal(msg)) => { + assert!(msg.contains("already exists"), "Error message: {}", msg); + } + _ => panic!("Expected Internal error with 'already exists'"), + } +} + +#[tokio::test] +async fn test_update_ledger_state() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let txn_id = Uuid::new_v4(); + let items_blob = r#"[{"table":"t1"}]"#; + + // Create entry + engine + .write_ledger_entry( + &keyspace, + txn_id, + TransactionState::Preparing, + 1000000, + None, + None, + items_blob, + ) + .await + .unwrap(); + + // Update state to committing + engine + .update_ledger_state(&keyspace, txn_id, TransactionState::Committing) + .await + .expect("Update state failed"); + + // Verify state changed + let entry = engine + .read_ledger_entry(&keyspace, txn_id) + .await + .unwrap() + .unwrap(); + assert_eq!(entry.state, "COMMITTING"); + + // Update to cancelling + engine + .update_ledger_state(&keyspace, txn_id, TransactionState::Cancelling) + .await + .unwrap(); + + let entry = engine + .read_ledger_entry(&keyspace, txn_id) + .await + .unwrap() + .unwrap(); + assert_eq!(entry.state, "CANCELLING"); +} + +#[tokio::test] +async fn test_delete_ledger_entry() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let txn_id = Uuid::new_v4(); + let items_blob = r#"[{"table":"t1"}]"#; + + // Create entry + engine + .write_ledger_entry( + &keyspace, + txn_id, + TransactionState::Preparing, + 1000000, + None, + None, + items_blob, + ) + .await + .unwrap(); + + // Verify it exists + assert!( + engine + .read_ledger_entry(&keyspace, txn_id) + .await + .unwrap() + .is_some() + ); + + // Delete it + engine + .delete_ledger_entry(&keyspace, txn_id) + .await + .expect("Delete failed"); + + // Verify it's gone + assert!( + engine + .read_ledger_entry(&keyspace, txn_id) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn test_scan_old_transactions() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let items_blob = r#"[{"table":"t1"}]"#; + + // Create transactions with different timestamps + let old_txn_1 = Uuid::new_v4(); + let old_txn_2 = Uuid::new_v4(); + let recent_txn = Uuid::new_v4(); + + engine + .write_ledger_entry( + &keyspace, + old_txn_1, + TransactionState::Preparing, + 1000, + None, + None, + items_blob, + ) + .await + .unwrap(); + engine + .write_ledger_entry( + &keyspace, + old_txn_2, + TransactionState::Committing, + 2000, + None, + None, + items_blob, + ) + .await + .unwrap(); + engine + .write_ledger_entry( + &keyspace, + recent_txn, + TransactionState::Preparing, + 100000, + None, + None, + items_blob, + ) + .await + .unwrap(); + + // Scan for transactions older than 50000 + let old_txns = engine + .scan_old_transactions(&keyspace, 50000) + .await + .expect("Scan failed"); + + assert_eq!(old_txns.len(), 2, "Should find 2 old transactions"); + + let old_ids: Vec = old_txns.iter().map(|e| e.txn_id).collect(); + assert!(old_ids.contains(&old_txn_1)); + assert!(old_ids.contains(&old_txn_2)); + assert!(!old_ids.contains(&recent_txn)); +} + +#[tokio::test] +async fn test_read_nonexistent_ledger_entry() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let txn_id = Uuid::new_v4(); + let entry = engine + .read_ledger_entry(&keyspace, txn_id) + .await + .expect("Query should succeed"); + + assert!(entry.is_none(), "Should return None for nonexistent entry"); +} diff --git a/crates/storage-cassandra/tests/direct/users.rs b/crates/storage-cassandra/tests/direct/users.rs new file mode 100644 index 00000000..1ab865b5 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/users.rs @@ -0,0 +1,426 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for User management operations for `CassandraCatalogStore`. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_create_user() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + println!("✓ User created successfully"); + + let result = catalog_store + .create_user(&account_id, &user_name, None) + .await; + match result { + Err(extenddb_storage::management_store::OpError::AlreadyExists(_)) => { + println!("✓ Duplicate user correctly rejected"); + } + other => panic!("Expected AlreadyExists, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_foreign_key_checks() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let nonexistent_account = unique_test_account(); + let user_name = format!("testuser_{}", unique_test_id()); + + let result = catalog_store + .create_user(&nonexistent_account, &user_name, None) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Foreign key check correctly rejected user creation"); + } + other => panic!("Expected NotFound for account, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_delete_user() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + // Setup: create account and user + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + // Happy case: delete existing user + catalog_store + .delete_user(&account_id, &user_name) + .await + .expect("Failed to delete user"); + + println!("✓ User deleted successfully"); + + // Unhappy case: delete non-existent user + let result = catalog_store.delete_user(&account_id, &user_name).await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Delete non-existent user correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_list_users() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Happy case: list users when none exist + let users = catalog_store + .list_users(&account_id) + .await + .expect("Failed to list users"); + + assert_eq!(users.len(), 0, "Expected no users initially"); + println!("✓ List empty users works"); + + // Create multiple users + for i in 0..3 { + let user_name = format!("testuser_{}_{}", unique_test_id(), i); + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + } + + // Happy case: list multiple users + let users = catalog_store + .list_users(&account_id) + .await + .expect("Failed to list users"); + + assert_eq!(users.len(), 3, "Expected 3 users"); + println!("✓ Listed {} users successfully", users.len()); + + // Verify structure: (account_id, user_name, user_arn, has_password, created_at) + for (aid, uname, arn, has_pw, _created) in &users { + assert_eq!(aid, &account_id); + assert!(uname.starts_with("testuser_")); + assert!(arn.contains(&account_id)); + assert!(!(*has_pw), "No password set"); + } + + println!("✓ All users have correct structure"); + } + + #[tokio::test] + async fn test_get_user_detail() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + + // Need encryption key for access keys + use extenddb_storage::bootstrapper::helpers::generate_encryption_key; + let enc_key = generate_encryption_key(); + let catalog_store = extenddb_storage_cassandra::CassandraCatalogStore::with_encryption_key( + engine.session_arc(), + config.keyspace_prefix.clone(), + config.datacenter.clone(), + config.replication_factor, + enc_key, + ); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + // Add access key + catalog_store + .create_access_key(&account_id, &user_name) + .await + .expect("Failed to create access key"); + + // Add tags + let tags = vec![("Env".to_string(), "Test".to_string())]; + catalog_store + .tag_user(&account_id, &user_name, &tags) + .await + .expect("Failed to tag user"); + + // Happy case: get user detail + let detail = catalog_store + .get_user_detail(&account_id, &user_name) + .await + .expect("Failed to get user detail") + .expect("User detail should exist"); + + assert_eq!(detail.keys.len(), 1); + assert_eq!(detail.policies.len(), 1); // SelfServicePolicy + assert_eq!(detail.policies[0], "SelfServicePolicy"); + assert_eq!(detail.tags.len(), 1); + assert_eq!(detail.groups.len(), 0); + + println!("✓ User detail retrieved successfully"); + println!(" Keys: {}", detail.keys.len()); + println!(" Policies: {}", detail.policies.len()); + println!(" Tags: {}", detail.tags.len()); + println!(" Groups: {}", detail.groups.len()); + + // Unhappy case: get detail for non-existent user + let detail = catalog_store + .get_user_detail(&account_id, "nonexistent_user") + .await + .expect("Failed to get user detail"); + + assert!(detail.is_none(), "Non-existent user should return None"); + println!("✓ Non-existent user returns None"); + } + + #[tokio::test] + async fn test_user_tags() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + // Setup + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + // Happy case: add tags + let tags = vec![ + ("Environment".to_string(), "Production".to_string()), + ("Team".to_string(), "Platform".to_string()), + ]; + + catalog_store + .tag_user(&account_id, &user_name, &tags) + .await + .expect("Failed to tag user"); + + println!("✓ Tagged user successfully"); + + // Happy case: list tags + let fetched_tags = catalog_store + .list_user_tags(&account_id, &user_name) + .await + .expect("Failed to list tags"); + + assert_eq!(fetched_tags.len(), 2); + assert!(fetched_tags.contains(&("Environment".to_string(), "Production".to_string()))); + assert!(fetched_tags.contains(&("Team".to_string(), "Platform".to_string()))); + println!("✓ Listed {} tags correctly", fetched_tags.len()); + + // Happy case: update existing tag (upsert) + let updated_tags = vec![("Environment".to_string(), "Staging".to_string())]; + + catalog_store + .tag_user(&account_id, &user_name, &updated_tags) + .await + .expect("Failed to update tag"); + + let fetched_tags = catalog_store + .list_user_tags(&account_id, &user_name) + .await + .expect("Failed to list tags after update"); + + assert_eq!(fetched_tags.len(), 2); + assert!(fetched_tags.contains(&("Environment".to_string(), "Staging".to_string()))); + println!("✓ Tag upsert works correctly"); + + // Happy case: untag + let tag_keys = vec!["Environment".to_string()]; + catalog_store + .untag_user(&account_id, &user_name, &tag_keys) + .await + .expect("Failed to untag user"); + + let fetched_tags = catalog_store + .list_user_tags(&account_id, &user_name) + .await + .expect("Failed to list tags after untag"); + + assert_eq!(fetched_tags.len(), 1); + assert!(fetched_tags.contains(&("Team".to_string(), "Platform".to_string()))); + println!("✓ Untag works correctly"); + + // Unhappy case: tag non-existent user + let result = catalog_store + .tag_user(&account_id, "nonexistent_user", &tags) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Tag non-existent user correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_user_passwords() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + // Setup + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Create user with password + let password = "TestPassword123!"; + let password_hash = + bcrypt::hash(password, bcrypt::DEFAULT_COST).expect("Failed to hash password"); + + catalog_store + .create_user(&account_id, &user_name, Some(&password_hash)) + .await + .expect("Failed to create user with password"); + + println!("✓ User with password created"); + + // Happy case: verify correct password + let verified = catalog_store + .verify_iam_user_password(&account_id, &user_name, password) + .await + .expect("Failed to verify password"); + + assert!(verified, "Password should verify"); + println!("✓ Correct password verified"); + + // Unhappy case: verify wrong password + let verified = catalog_store + .verify_iam_user_password(&account_id, &user_name, "WrongPassword") + .await + .expect("Failed to verify password"); + + assert!(!verified, "Wrong password should not verify"); + println!("✓ Wrong password correctly rejected"); + + // Happy case: change password + let new_password = "NewPassword456!"; + let new_hash = + bcrypt::hash(new_password, bcrypt::DEFAULT_COST).expect("Failed to hash new password"); + + catalog_store + .change_user_password(&account_id, &user_name, &new_hash) + .await + .expect("Failed to change password"); + + // Verify old password no longer works + let verified = catalog_store + .verify_iam_user_password(&account_id, &user_name, password) + .await + .expect("Failed to verify password"); + + assert!(!verified, "Old password should not work"); + + // Verify new password works + let verified = catalog_store + .verify_iam_user_password(&account_id, &user_name, new_password) + .await + .expect("Failed to verify new password"); + + assert!(verified, "New password should verify"); + println!("✓ Password changed successfully"); + + // Unhappy case: change password for non-existent user + let result = catalog_store + .change_user_password(&account_id, "nonexistent", &new_hash) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Change password for non-existent user correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + } +} diff --git a/crates/storage-cassandra/tests/direct_integration.rs b/crates/storage-cassandra/tests/direct_integration.rs new file mode 100644 index 00000000..43eb28b8 --- /dev/null +++ b/crates/storage-cassandra/tests/direct_integration.rs @@ -0,0 +1,59 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct storage-trait integration tests, ported from the original +//! extenddb-cassandra-plugin repository (its tests/rust suite). They exercise +//! the Cassandra engine's trait implementations directly against a live node +//! at 127.0.0.1:9042 — no HTTP server in between — and skip themselves when +//! no Cassandra is reachable (see helpers::skip_without_cassandra). +//! +//! One binary, one module per area, sharing tests/common/mod.rs, which is the +//! in-tree descendant of the plug-in repo's helpers.rs. + +#[path = "common/mod.rs"] +mod helpers; + +#[path = "direct/access_keys.rs"] +mod access_keys; +#[path = "direct/accounts.rs"] +mod accounts; +#[path = "direct/admin_store.rs"] +mod admin_store; +#[path = "direct/authorization_store.rs"] +mod authorization_store; +#[path = "direct/backup_engine.rs"] +mod backup_engine; +#[path = "direct/cassandra_engine.rs"] +mod cassandra_engine; +#[path = "direct/delete_item.rs"] +mod delete_item; +#[path = "direct/groups.rs"] +mod groups; +#[path = "direct/index.rs"] +mod index; +#[path = "direct/metadata_engine.rs"] +mod metadata_engine; +#[path = "direct/policies.rs"] +mod policies; +#[path = "direct/put_get_item.rs"] +mod put_get_item; +#[path = "direct/query.rs"] +mod query; +#[path = "direct/roles.rs"] +mod roles; +#[path = "direct/scan.rs"] +mod scan; +#[path = "direct/settings_store.rs"] +mod settings_store; +#[path = "direct/streams.rs"] +mod streams; +#[path = "direct/table_engine.rs"] +mod table_engine; +#[path = "direct/transact_get_items.rs"] +mod transact_get_items; +#[path = "direct/transact_write_items.rs"] +mod transact_write_items; +#[path = "direct/transaction_ledger.rs"] +mod transaction_ledger; +#[path = "direct/users.rs"] +mod users; From df551859b88d524780be9c494717d5fca7c84b8e Mon Sep 17 00:00:00 2001 From: Scott Robinson Date: Fri, 18 Sep 2026 18:17:28 +0000 Subject: [PATCH 2/2] test: fold the metadata suite into the direct integration binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review suggestion from jcshepherd. The standalone metadata_integration binary held one test — the in-tree consolidation of the plug-in repo's four tag tests — while the ported suite carried those four originals, so merging the binaries also deduplicates: the consolidated phased lifecycle test replaces the four in direct/metadata_engine.rs (same assertions, one engine connection instead of four catalog-migration setups). It is parallel-safe like the rest of the suite. The TTL suite stays its own serial binary: its tests drive global sweep, repair, and audit passes that would see every concurrent test's tables, so the two binaries have deliberately opposite concurrency contracts. One fewer test binary to link, one fewer CI step. --- .github/workflows/integration-cassandra.yml | 2 - .../tests/direct/metadata_engine.rs | 173 ++++++------------ .../tests/metadata_integration.rs | 99 ---------- 3 files changed, 58 insertions(+), 216 deletions(-) delete mode 100644 crates/storage-cassandra/tests/metadata_integration.rs diff --git a/.github/workflows/integration-cassandra.yml b/.github/workflows/integration-cassandra.yml index 4c3eebbd..2fe0acc3 100644 --- a/.github/workflows/integration-cassandra.yml +++ b/.github/workflows/integration-cassandra.yml @@ -48,8 +48,6 @@ jobs: cache-on-failure: true - name: Unit tests run: cargo test -p extenddb-storage-cassandra --lib - - name: Metadata integration tests - run: cargo test -p extenddb-storage-cassandra --test metadata_integration -- --test-threads=1 - name: TTL integration tests run: cargo test -p extenddb-storage-cassandra --test ttl_integration -- --test-threads=1 - name: Direct storage-trait integration tests diff --git a/crates/storage-cassandra/tests/direct/metadata_engine.rs b/crates/storage-cassandra/tests/direct/metadata_engine.rs index 53ea8672..3d7717f8 100644 --- a/crates/storage-cassandra/tests/direct/metadata_engine.rs +++ b/crates/storage-cassandra/tests/direct/metadata_engine.rs @@ -1,54 +1,32 @@ // Copyright 2026 ExtendDB contributors // SPDX-License-Identifier: Apache-2.0 -//! Integration tests for MetadataEngine tag operations. +//! Direct integration tests for `MetadataEngine` operations that are not +//! TTL-specific. +//! +//! The plug-in repo's four tag tests were consolidated in-tree into the single +//! phased `test_resource_tag_lifecycle` before this suite was ported, so this +//! module carries the consolidated form rather than both. use extenddb_core::types::Tag; use extenddb_storage::MetadataEngine; use crate::helpers::setup_engine; -#[tokio::test] -async fn test_tag_and_list_tags() { - if crate::helpers::skip_without_cassandra() { - return; +fn tag(key: &str, value: &str) -> Tag { + Tag { + key: key.to_owned(), + value: value.to_owned(), } - let engine = setup_engine().await; - let arn = format!( - "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", - uuid::Uuid::new_v4().simple() - ); - - let tags = vec![ - Tag { - key: "env".to_string(), - value: "test".to_string(), - }, - Tag { - key: "owner".to_string(), - value: "alice".to_string(), - }, - ]; - - engine - .tag_resource(&arn, &tags) - .await - .expect("tag_resource should succeed"); - - let result = engine - .list_tags(&arn) - .await - .expect("list_tags should succeed"); - assert_eq!(result.len(), 2); - // Cassandra returns in clustering key order - assert_eq!(result[0].key, "env"); - assert_eq!(result[0].value, "test"); - assert_eq!(result[1].key, "owner"); - assert_eq!(result[1].value, "alice"); } +/// One phased pass over the tag lifecycle. Each phase asserts a distinct +/// behaviour — empty listing, exact multi-tag persistence and ordering, +/// same-key overwrite, and selective removal — but they share one engine +/// connection, because `setup_engine` reruns catalog migrations and dominated +/// the cost of testing these as four separate cases. #[tokio::test] -async fn test_tag_resource_upserts() { +async fn test_resource_tag_lifecycle() { if crate::helpers::skip_without_cassandra() { return; } @@ -58,95 +36,60 @@ async fn test_tag_resource_upserts() { uuid::Uuid::new_v4().simple() ); + // An untouched resource has no tags. + assert!( + engine + .list_tags(&arn) + .await + .expect("list_tags on an untagged resource") + .is_empty() + ); + + // Tags persist with their exact keys and values, in clustering-key order. engine - .tag_resource( - &arn, - &[Tag { - key: "env".to_string(), - value: "staging".to_string(), - }], - ) + .tag_resource(&arn, &[tag("env", "staging"), tag("owner", "alice")]) .await - .expect("first tag_resource should succeed"); + .expect("tag_resource"); + let tags = engine.list_tags(&arn).await.expect("list_tags"); + assert_eq!( + tags.iter() + .map(|t| (t.key.as_str(), t.value.as_str())) + .collect::>(), + vec![("env", "staging"), ("owner", "alice")] + ); - // Overwrite with new value + // Re-tagging an existing key replaces its value and leaves the other alone. engine - .tag_resource( - &arn, - &[Tag { - key: "env".to_string(), - value: "prod".to_string(), - }], - ) + .tag_resource(&arn, &[tag("env", "prod")]) .await - .expect("second tag_resource should succeed"); - - let result = engine + .expect("tag_resource overwrite"); + let tags = engine .list_tags(&arn) .await - .expect("list_tags should succeed"); - assert_eq!(result.len(), 1); - assert_eq!(result[0].value, "prod"); -} - -#[tokio::test] -async fn test_untag_resource() { - if crate::helpers::skip_without_cassandra() { - return; - } - let engine = setup_engine().await; - let arn = format!( - "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", - uuid::Uuid::new_v4().simple() + .expect("list_tags after upsert"); + assert_eq!( + tags.iter() + .map(|t| (t.key.as_str(), t.value.as_str())) + .collect::>(), + vec![("env", "prod"), ("owner", "alice")], + "an upsert must replace only the key it names" ); - let tags = vec![ - Tag { - key: "a".to_string(), - value: "1".to_string(), - }, - Tag { - key: "b".to_string(), - value: "2".to_string(), - }, - Tag { - key: "c".to_string(), - value: "3".to_string(), - }, - ]; + // Untagging removes exactly the named keys. engine - .tag_resource(&arn, &tags) + .tag_resource(&arn, &[tag("team", "storage")]) .await - .expect("tag_resource should succeed"); - + .expect("tag_resource third key"); engine - .untag_resource(&arn, &["a".to_string(), "c".to_string()]) - .await - .expect("untag_resource should succeed"); - - let result = engine - .list_tags(&arn) + .untag_resource(&arn, &["env".to_owned(), "team".to_owned()]) .await - .expect("list_tags should succeed"); - assert_eq!(result.len(), 1); - assert_eq!(result[0].key, "b"); - assert_eq!(result[0].value, "2"); -} - -#[tokio::test] -async fn test_list_tags_empty() { - if crate::helpers::skip_without_cassandra() { - return; - } - let engine = setup_engine().await; - let arn = format!( - "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", - uuid::Uuid::new_v4().simple() + .expect("untag_resource"); + let tags = engine.list_tags(&arn).await.expect("list_tags after untag"); + assert_eq!( + tags.iter() + .map(|t| (t.key.as_str(), t.value.as_str())) + .collect::>(), + vec![("owner", "alice")], + "untag must remove only the keys it names" ); - - let result = engine - .list_tags(&arn) - .await - .expect("list_tags should succeed"); - assert!(result.is_empty()); } diff --git a/crates/storage-cassandra/tests/metadata_integration.rs b/crates/storage-cassandra/tests/metadata_integration.rs deleted file mode 100644 index 9a9b5877..00000000 --- a/crates/storage-cassandra/tests/metadata_integration.rs +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2026 ExtendDB contributors -// SPDX-License-Identifier: Apache-2.0 - -//! Integration tests for `MetadataEngine` operations that are not TTL-specific. -//! -//! These exercise the Cassandra adapter directly. The SDK-level suites under -//! `tests/rust` and `tests/python` cover the same semantics through the API, but -//! CI runs those against PostgreSQL, SQLite, and MongoDB only — there is no -//! Cassandra integration workflow — so this is the only coverage that reaches -//! the Cassandra implementation of these calls. - -#[path = "common/mod.rs"] -mod helpers; - -use extenddb_core::types::Tag; -use extenddb_storage::MetadataEngine; - -use crate::helpers::setup_engine; - -fn tag(key: &str, value: &str) -> Tag { - Tag { - key: key.to_owned(), - value: value.to_owned(), - } -} - -/// One phased pass over the tag lifecycle. Each phase asserts a distinct -/// behaviour — empty listing, exact multi-tag persistence and ordering, -/// same-key overwrite, and selective removal — but they share one engine -/// connection, because `setup_engine` reruns catalog migrations and dominated -/// the cost of testing these as four separate cases. -#[tokio::test] -async fn test_resource_tag_lifecycle() { - if crate::helpers::skip_without_cassandra() { - return; - } - let engine = setup_engine().await; - let arn = format!( - "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", - uuid::Uuid::new_v4().simple() - ); - - // An untouched resource has no tags. - assert!( - engine - .list_tags(&arn) - .await - .expect("list_tags on an untagged resource") - .is_empty() - ); - - // Tags persist with their exact keys and values, in clustering-key order. - engine - .tag_resource(&arn, &[tag("env", "staging"), tag("owner", "alice")]) - .await - .expect("tag_resource"); - let tags = engine.list_tags(&arn).await.expect("list_tags"); - assert_eq!( - tags.iter() - .map(|t| (t.key.as_str(), t.value.as_str())) - .collect::>(), - vec![("env", "staging"), ("owner", "alice")] - ); - - // Re-tagging an existing key replaces its value and leaves the other alone. - engine - .tag_resource(&arn, &[tag("env", "prod")]) - .await - .expect("tag_resource overwrite"); - let tags = engine - .list_tags(&arn) - .await - .expect("list_tags after upsert"); - assert_eq!( - tags.iter() - .map(|t| (t.key.as_str(), t.value.as_str())) - .collect::>(), - vec![("env", "prod"), ("owner", "alice")], - "an upsert must replace only the key it names" - ); - - // Untagging removes exactly the named keys. - engine - .tag_resource(&arn, &[tag("team", "storage")]) - .await - .expect("tag_resource third key"); - engine - .untag_resource(&arn, &["env".to_owned(), "team".to_owned()]) - .await - .expect("untag_resource"); - let tags = engine.list_tags(&arn).await.expect("list_tags after untag"); - assert_eq!( - tags.iter() - .map(|t| (t.key.as_str(), t.value.as_str())) - .collect::>(), - vec![("owner", "alice")], - "untag must remove only the keys it names" - ); -}