diff --git a/.github/workflows/integration-cassandra.yml b/.github/workflows/integration-cassandra.yml index 7c446558..2fe0acc3 100644 --- a/.github/workflows/integration-cassandra.yml +++ b/.github/workflows/integration-cassandra.yml @@ -48,7 +48,10 @@ 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 + # 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/metadata_integration.rs b/crates/storage-cassandra/tests/direct/metadata_engine.rs similarity index 85% rename from crates/storage-cassandra/tests/metadata_integration.rs rename to crates/storage-cassandra/tests/direct/metadata_engine.rs index 9a9b5877..3d7717f8 100644 --- a/crates/storage-cassandra/tests/metadata_integration.rs +++ b/crates/storage-cassandra/tests/direct/metadata_engine.rs @@ -1,16 +1,12 @@ // Copyright 2026 ExtendDB contributors // SPDX-License-Identifier: Apache-2.0 -//! Integration tests for `MetadataEngine` operations that are not TTL-specific. +//! Direct 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; +//! 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; 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;