diff --git a/client/src/services/grpc_client.rs b/client/src/services/grpc_client.rs index c4481e4..f5c3c8d 100644 --- a/client/src/services/grpc_client.rs +++ b/client/src/services/grpc_client.rs @@ -14,7 +14,10 @@ use common::proto::multieko2::table_script::{ use common::proto::multieko2::tables_data::{ tables_data_client::TablesDataClient, GetTableDataByPositionRequest, + GetTableDataRequest, // ADD THIS GetTableDataResponse, + DeleteTableDataRequest, // ADD THIS + DeleteTableDataResponse, // ADD THIS GetTableDataCountRequest, PostTableDataRequest, PostTableDataResponse, PutTableDataRequest, PutTableDataResponse, @@ -116,7 +119,7 @@ impl GrpcClient { Ok(response.into_inner()) } - // NEW Methods for TablesData service + // Existing TablesData methods pub async fn get_table_data_count( &mut self, profile_name: String, @@ -135,7 +138,7 @@ impl GrpcClient { Ok(response.into_inner().count as u64) } -pub async fn get_table_data_by_position( + pub async fn get_table_data_by_position( &mut self, profile_name: String, table_name: String, @@ -155,18 +158,58 @@ pub async fn get_table_data_by_position( Ok(response.into_inner()) } + // ADD THIS: Missing get_table_data method + pub async fn get_table_data( + &mut self, + profile_name: String, + table_name: String, + id: i64, + ) -> Result { + let grpc_request = GetTableDataRequest { + profile_name, + table_name, + id, + }; + let request = tonic::Request::new(grpc_request); + let response = self + .tables_data_client + .get_table_data(request) + .await + .context("gRPC GetTableData call failed")?; + Ok(response.into_inner()) + } + + // ADD THIS: Missing delete_table_data method + pub async fn delete_table_data( + &mut self, + profile_name: String, + table_name: String, + record_id: i64, + ) -> Result { + let grpc_request = DeleteTableDataRequest { + profile_name, + table_name, + record_id, + }; + let request = tonic::Request::new(grpc_request); + let response = self + .tables_data_client + .delete_table_data(request) + .await + .context("gRPC DeleteTableData call failed")?; + Ok(response.into_inner()) + } + pub async fn post_table_data( &mut self, profile_name: String, table_name: String, - // CHANGE THIS: Accept the pre-converted data data: HashMap, ) -> Result { - // The conversion logic is now gone from here. let grpc_request = PostTableDataRequest { profile_name, table_name, - data, // This is now the correct type + data, }; let request = tonic::Request::new(grpc_request); let response = self @@ -182,15 +225,13 @@ pub async fn get_table_data_by_position( profile_name: String, table_name: String, id: i64, - // CHANGE THIS: Accept the pre-converted data data: HashMap, ) -> Result { - // The conversion logic is now gone from here. let grpc_request = PutTableDataRequest { profile_name, table_name, id, - data, // This is now the correct type + data, }; let request = tonic::Request::new(grpc_request); let response = self diff --git a/client/tests/form_tests.rs b/client/tests/form/gui/form_tests.rs similarity index 100% rename from client/tests/form_tests.rs rename to client/tests/form/gui/form_tests.rs diff --git a/client/tests/form/gui/mod.rs b/client/tests/form/gui/mod.rs new file mode 100644 index 0000000..1f4fbaa --- /dev/null +++ b/client/tests/form/gui/mod.rs @@ -0,0 +1 @@ +pub mod form_tests; diff --git a/client/tests/form/mod.rs b/client/tests/form/mod.rs new file mode 100644 index 0000000..0d35e92 --- /dev/null +++ b/client/tests/form/mod.rs @@ -0,0 +1,2 @@ +pub mod gui; +pub mod requests; diff --git a/client/tests/form/requests/form_request_tests.rs b/client/tests/form/requests/form_request_tests.rs new file mode 100644 index 0000000..776d699 --- /dev/null +++ b/client/tests/form/requests/form_request_tests.rs @@ -0,0 +1,159 @@ +// client/tests/form_request_tests.rs +use rstest::{fixture, rstest}; +use client::services::grpc_client::GrpcClient; +use client::state::pages::form::FormState; +use client::state::pages::canvas_state::CanvasState; +use prost_types::Value; +use prost_types::value::Kind; +use std::collections::HashMap; +use tonic::Status; +use tokio::time::{timeout, Duration}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// ======================================================================== +// HELPER FUNCTIONS AND UTILITIES +// ======================================================================== + +/// Generate unique identifiers for test isolation using timestamp +fn generate_unique_id() -> String { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + // Ensure we always get a 12-character hex string by padding with zeros + format!("{:012x}", timestamp % 1_000_000_000_000u128) +} + +/// Helper function to create string value +fn create_string_value(s: &str) -> Value { + Value { + kind: Some(Kind::StringValue(s.to_string())) + } +} + +/// Helper function to create number value +fn create_number_value(n: f64) -> Value { + Value { + kind: Some(Kind::NumberValue(n)) + } +} + +/// Helper function to create boolean value +fn create_bool_value(b: bool) -> Value { + Value { + kind: Some(Kind::BoolValue(b)) + } +} + +/// Helper function to create null value +fn create_null_value() -> Value { + Value { + kind: Some(Kind::NullValue(0)) + } +} + +/// Check if backend is available +async fn is_backend_available() -> bool { + if std::env::var("SKIP_BACKEND_TESTS").is_ok() { + return false; + } + + match GrpcClient::new().await { + Ok(_) => true, + Err(_) => false, + } +} + +/// Skip test if backend is not available +macro_rules! skip_if_backend_unavailable { + () => { + if !is_backend_available().await { + println!("Backend unavailable - skipping test"); + return; + } + }; +} + +// ======================================================================== +// TEST CONTEXT AND FIXTURES +// ======================================================================== + +#[derive(Clone)] +struct FormTestContext { + client: GrpcClient, + profile_name: String, + table_name: String, +} + +impl FormTestContext { + /// Create test form data for insertion + fn create_test_form_data(&self) -> HashMap { + let mut data = HashMap::new(); + data.insert("firma".to_string(), create_string_value("Test Company Ltd")); + data.insert("telefon".to_string(), create_string_value("+421123456789")); + data.insert("email".to_string(), create_string_value("test@company.com")); + data.insert("kz".to_string(), create_string_value("KZ123")); + data.insert("ulica".to_string(), create_string_value("Test Street 123")); + data.insert("mesto".to_string(), create_string_value("Test City")); + data + } + + /// Create minimal valid form data + fn create_minimal_form_data(&self) -> HashMap { + let mut data = HashMap::new(); + data.insert("firma".to_string(), create_string_value("Minimal Company")); + data + } + + /// Create form data with invalid fields + fn create_invalid_form_data(&self) -> HashMap { + let mut data = HashMap::new(); + data.insert("firma".to_string(), create_string_value("Test Company")); + data.insert("nonexistent_field".to_string(), create_string_value("Invalid")); + data + } + + /// Create form data with type mismatches + fn create_type_mismatch_data(&self) -> HashMap { + let mut data = HashMap::new(); + data.insert("firma".to_string(), create_string_value("Test Company")); + data.insert("age".to_string(), create_string_value("thirty")); // String for number field + data + } +} + +#[fixture] +async fn form_test_context() -> FormTestContext { + let client = GrpcClient::new() + .await + .expect("Failed to create gRPC client for test"); + + let unique_id = generate_unique_id(); + let profile_name = format!("test_profile_{}", unique_id); + let table_name = format!("test_table_{}", unique_id); + + FormTestContext { + client, + profile_name, + table_name, + } +} + +#[fixture] +async fn populated_test_context() -> FormTestContext { + let mut context = form_test_context().await; + + // Pre-populate with test data if backend is available + if is_backend_available().await { + let test_data = context.create_test_form_data(); + let _ = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + test_data, + ).await; + } + + context +} + +include!("form_request_tests2.rs"); diff --git a/client/tests/form/requests/form_request_tests2.rs b/client/tests/form/requests/form_request_tests2.rs new file mode 100644 index 0000000..d1837a0 --- /dev/null +++ b/client/tests/form/requests/form_request_tests2.rs @@ -0,0 +1,859 @@ +#[rstest] +#[tokio::test] +async fn test_post_table_data_success(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let test_data = context.create_test_form_data(); + + let result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + test_data.clone(), + ).await; + + match result { + Ok(response) => { + assert!(response.success, "POST operation should succeed"); + assert!(response.inserted_id > 0, "Should return valid inserted ID"); + assert!(!response.message.is_empty(), "Should return non-empty message"); + println!("POST successful: ID {}, Message: {}", response.inserted_id, response.message); + } + Err(e) => { + if let Some(status) = e.downcast_ref::() { + if status.code() == tonic::Code::Unavailable { + println!("Backend unavailable - test cannot run"); + return; + } + } + panic!("POST request failed unexpectedly: {}", e); + } + } +} + +#[rstest] +#[tokio::test] +async fn test_post_table_data_minimal_data(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let minimal_data = context.create_minimal_form_data(); + + let result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + minimal_data, + ).await; + + assert!(result.is_ok(), "POST with minimal data should succeed"); + let response = result.unwrap(); + assert!(response.success); + assert!(response.inserted_id > 0); +} + +#[rstest] +#[tokio::test] +async fn test_get_table_data_count_success(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let result = context.client.get_table_data_count( + context.profile_name.clone(), + context.table_name.clone(), + ).await; + + match result { + Ok(count) => { + assert!(count >= 0, "Count should be non-negative"); + println!("GET count successful: {}", count); + } + Err(e) => { + if let Some(status) = e.downcast_ref::() { + if status.code() == tonic::Code::Unavailable { + println!("Backend unavailable - test cannot run"); + return; + } + } + panic!("GET count request failed unexpectedly: {}", e); + } + } +} + +#[rstest] +#[tokio::test] +async fn test_get_table_data_by_id_with_existing_record(#[future] populated_test_context: FormTestContext) { + let mut context = populated_test_context.await; + skip_if_backend_unavailable!(); + + // First create a record + let test_data = context.create_test_form_data(); + let post_result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + test_data.clone(), + ).await; + + if let Ok(post_response) = post_result { + let created_id = post_response.inserted_id; + + // Now try to get it + let get_result = context.client.get_table_data( + context.profile_name.clone(), + context.table_name.clone(), + created_id, + ).await; + + match get_result { + Ok(response) => { + assert!(!response.data.is_empty(), "Should return data fields"); + println!("GET by ID successful: {} fields", response.data.len()); + + // Verify some data matches + if let Some(firma_value) = response.data.get("firma") { + assert_eq!(firma_value, "Test Company Ltd"); + } + } + Err(e) => panic!("GET by ID failed unexpectedly: {}", e), + } + } else { + println!("Could not create test record, skipping GET test"); + } +} + +#[rstest] +#[tokio::test] +async fn test_get_table_data_by_nonexistent_id(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let nonexistent_id = 99999; + let result = context.client.get_table_data( + context.profile_name.clone(), + context.table_name.clone(), + nonexistent_id, + ).await; + + assert!(result.is_err(), "GET should fail for nonexistent ID"); + if let Some(status) = result.unwrap_err().downcast_ref::() { + assert_eq!(status.code(), tonic::Code::NotFound); + } +} + +#[rstest] +#[tokio::test] +async fn test_put_table_data_success(#[future] populated_test_context: FormTestContext) { + let mut context = populated_test_context.await; + skip_if_backend_unavailable!(); + + // First create a record + let test_data = context.create_test_form_data(); + let post_result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + test_data, + ).await; + + if let Ok(post_response) = post_result { + let created_id = post_response.inserted_id; + + // Update the record + let mut update_data = HashMap::new(); + update_data.insert("firma".to_string(), create_string_value("Updated Company Name")); + update_data.insert("telefon".to_string(), create_string_value("+421987654321")); + + let put_result = context.client.put_table_data( + context.profile_name.clone(), + context.table_name.clone(), + created_id, + update_data, + ).await; + + match put_result { + Ok(response) => { + assert!(response.success, "PUT operation should succeed"); + assert_eq!(response.updated_id, created_id, "Should return correct updated ID"); + println!("PUT successful: ID {}, Message: {}", response.updated_id, response.message); + } + Err(e) => panic!("PUT request failed unexpectedly: {}", e), + } + } else { + println!("Could not create test record, skipping PUT test"); + } +} + +#[rstest] +#[tokio::test] +async fn test_put_table_data_nonexistent_id(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let nonexistent_id = 99999; + let mut update_data = HashMap::new(); + update_data.insert("firma".to_string(), create_string_value("Updated Company")); + + let result = context.client.put_table_data( + context.profile_name.clone(), + context.table_name.clone(), + nonexistent_id, + update_data, + ).await; + + assert!(result.is_err(), "PUT should fail for nonexistent ID"); + if let Some(status) = result.unwrap_err().downcast_ref::() { + assert_eq!(status.code(), tonic::Code::NotFound); + } +} + +#[rstest] +#[tokio::test] +async fn test_delete_table_data_success(#[future] populated_test_context: FormTestContext) { + let mut context = populated_test_context.await; + skip_if_backend_unavailable!(); + + // First create a record + let test_data = context.create_test_form_data(); + let post_result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + test_data, + ).await; + + if let Ok(post_response) = post_result { + let created_id = post_response.inserted_id; + + let delete_result = context.client.delete_table_data( + context.profile_name.clone(), + context.table_name.clone(), + created_id, + ).await; + + match delete_result { + Ok(response) => { + assert!(response.success, "DELETE operation should succeed"); + println!("DELETE successful for ID {}", created_id); + } + Err(e) => panic!("DELETE request failed unexpectedly: {}", e), + } + } else { + println!("Could not create test record, skipping DELETE test"); + } +} + +#[rstest] +#[tokio::test] +async fn test_delete_table_data_nonexistent_id(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let nonexistent_id = 99999; + let result = context.client.delete_table_data( + context.profile_name.clone(), + context.table_name.clone(), + nonexistent_id, + ).await; + + // DELETE should succeed even for nonexistent IDs (idempotent operation) + assert!(result.is_ok(), "DELETE should not fail for nonexistent ID"); + let response = result.unwrap(); + assert!(response.success, "DELETE should report success even for nonexistent ID"); +} + +// ======================================================================== +// ERROR HANDLING AND VALIDATION TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_invalid_profile_and_table_errors(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let bogus_profile = "profile_does_not_exist".to_string(); + let bogus_table = "table_does_not_exist".to_string(); + + let result = context.client.get_table_data_count(bogus_profile, bogus_table).await; + + assert!(result.is_err(), "Expected error for non-existent profile/table"); + if let Some(status) = result.unwrap_err().downcast_ref::() { + assert_eq!(status.code(), tonic::Code::NotFound, "Expected NotFound for non-existent profile"); + } +} + +#[rstest] +#[tokio::test] +async fn test_invalid_column_validation(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let invalid_data = context.create_invalid_form_data(); + + let result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + invalid_data, + ).await; + + assert!(result.is_err(), "Expected error for undefined column"); + if let Some(status) = result.unwrap_err().downcast_ref::() { + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!(status.message().contains("Invalid column") || + status.message().contains("nonexistent")); + } +} + +#[rstest] +#[tokio::test] +async fn test_data_type_validation(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let type_mismatch_data = context.create_type_mismatch_data(); + + let result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + type_mismatch_data, + ).await; + + assert!(result.is_err(), "Expected error for wrong data type"); + if let Some(status) = result.unwrap_err().downcast_ref::() { + assert_eq!(status.code(), tonic::Code::InvalidArgument); + } +} + +#[rstest] +#[tokio::test] +async fn test_empty_data_validation(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let empty_data = HashMap::new(); + + let result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + empty_data, + ).await; + + assert!(result.is_err(), "Expected error for empty data"); + if let Some(status) = result.unwrap_err().downcast_ref::() { + assert_eq!(status.code(), tonic::Code::InvalidArgument); + } +} + +// ======================================================================== +// SOFT DELETE BEHAVIOR TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_soft_delete_behavior_comprehensive(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + // 1. Create a record + let test_data = context.create_test_form_data(); + let post_result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + test_data, + ).await; + + if let Ok(post_response) = post_result { + let record_id = post_response.inserted_id; + println!("Created record with ID {}", record_id); + + // 2. Verify count before deletion + let count_before = context.client.get_table_data_count( + context.profile_name.clone(), + context.table_name.clone() + ).await.unwrap_or(0); + assert!(count_before >= 1, "Count should be at least 1 after creation"); + + // 3. Soft-delete the record + let delete_result = context.client.delete_table_data( + context.profile_name.clone(), + context.table_name.clone(), + record_id, + ).await; + + assert!(delete_result.is_ok(), "Delete operation should succeed"); + println!("Soft-deleted record {}", record_id); + + // 4. Verify count decreased after deletion + let count_after = context.client.get_table_data_count( + context.profile_name.clone(), + context.table_name.clone() + ).await.unwrap_or(0); + assert_eq!(count_after, count_before - 1, "Count should decrease by 1 after soft delete"); + + // 5. Try to GET the soft-deleted record + let get_result = context.client.get_table_data( + context.profile_name.clone(), + context.table_name.clone(), + record_id, + ).await; + + assert!(get_result.is_err(), "Should not be able to GET a soft-deleted record"); + if let Some(status) = get_result.unwrap_err().downcast_ref::() { + assert_eq!(status.code(), tonic::Code::NotFound); + } + println!("Correctly failed to GET soft-deleted record"); + } else { + println!("Could not create test record for soft delete test"); + } +} + +// ======================================================================== +// POSITIONAL RETRIEVAL TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_positional_retrieval_comprehensive(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + // 1. Create multiple records + let test_names = ["Alice Corp", "Bob Industries", "Charlie Ltd"]; + let mut created_ids = Vec::new(); + + for (i, name) in test_names.iter().enumerate() { + let mut data = HashMap::new(); + data.insert("firma".to_string(), create_string_value(name)); + data.insert("kz".to_string(), create_string_value(&format!("KZ{}", i + 1))); + + if let Ok(response) = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + data, + ).await { + created_ids.push(response.inserted_id); + } + } + + if created_ids.len() < 3 { + println!("Could not create enough test records for positional test"); + return; + } + + println!("Created {} records with IDs: {:?}", created_ids.len(), created_ids); + + // 2. Test valid positional retrieval + for i in 0..3 { + let position = (i + 1) as i32; + let result = context.client.get_table_data_by_position( + context.profile_name.clone(), + context.table_name.clone(), + position, + ).await; + + match result { + Ok(response) => { + assert!(!response.data.is_empty(), "Position {} should return data", position); + if let Some(firma_value) = response.data.get("firma") { + assert!(test_names.contains(&firma_value.as_str()), + "Returned firma '{}' should be one of our test names", firma_value); + } + println!("Successfully retrieved record at position {}", position); + } + Err(e) => { + println!("Failed to get record at position {}: {}", position, e); + } + } + } + + // 3. Test out-of-bounds position + let oob_position = 100; + let result_oob = context.client.get_table_data_by_position( + context.profile_name.clone(), + context.table_name.clone(), + oob_position, + ).await; + + assert!(result_oob.is_err(), "Should fail for out-of-bounds position"); + if let Some(status) = result_oob.unwrap_err().downcast_ref::() { + assert_eq!(status.code(), tonic::Code::NotFound); + } + + // 4. Test invalid position (≤ 0) + let invalid_positions = [0, -1, -5]; + for invalid_pos in invalid_positions { + let result_invalid = context.client.get_table_data_by_position( + context.profile_name.clone(), + context.table_name.clone(), + invalid_pos, + ).await; + + assert!(result_invalid.is_err(), "Should fail for invalid position {}", invalid_pos); + if let Some(status) = result_invalid.unwrap_err().downcast_ref::() { + assert_eq!(status.code(), tonic::Code::InvalidArgument); + } + } +} + +// ======================================================================== +// WORKFLOW AND INTEGRATION TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_complete_crud_workflow(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let test_data = context.create_test_form_data(); + + // 1. CREATE - Post data + let post_result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + test_data.clone(), + ).await; + + let created_id = match post_result { + Ok(response) => { + assert!(response.success, "POST should succeed"); + println!("Workflow: Created record with ID {}", response.inserted_id); + response.inserted_id + } + Err(e) => { + if let Some(status) = e.downcast_ref::() { + if status.code() == tonic::Code::Unavailable { + println!("Workflow test skipped - backend not available"); + return; + } + } + panic!("Workflow POST failed unexpectedly: {}", e); + } + }; + + // 2. READ - Get the created data + let get_result = context.client.get_table_data( + context.profile_name.clone(), + context.table_name.clone(), + created_id, + ).await; + + let get_response = get_result.expect("Workflow GET should succeed"); + if let Some(firma_value) = get_response.data.get("firma") { + assert_eq!(firma_value, "Test Company Ltd", "Retrieved data should match created data"); + } + println!("Workflow: Verified created data"); + + // 3. UPDATE - Modify the data + let mut update_data = HashMap::new(); + update_data.insert("firma".to_string(), create_string_value("Updated in Workflow")); + update_data.insert("telefon".to_string(), create_string_value("+421999888777")); + + let put_result = context.client.put_table_data( + context.profile_name.clone(), + context.table_name.clone(), + created_id, + update_data, + ).await; + + let put_response = put_result.expect("Workflow PUT should succeed"); + assert!(put_response.success, "PUT should succeed"); + assert_eq!(put_response.updated_id, created_id, "PUT should return correct ID"); + println!("Workflow: Updated record"); + + // 4. VERIFY UPDATE - Get updated data + let get_updated_result = context.client.get_table_data( + context.profile_name.clone(), + context.table_name.clone(), + created_id, + ).await; + + let get_updated_response = get_updated_result.expect("Workflow GET after update should succeed"); + if let Some(firma_value) = get_updated_response.data.get("firma") { + assert_eq!(firma_value, "Updated in Workflow", "Data should be updated"); + } + println!("Workflow: Verified updated data"); + + // 5. DELETE - Remove the data + let delete_result = context.client.delete_table_data( + context.profile_name.clone(), + context.table_name.clone(), + created_id, + ).await; + + let delete_response = delete_result.expect("Workflow DELETE should succeed"); + assert!(delete_response.success, "DELETE should succeed"); + println!("Workflow: Deleted record"); + + // 6. VERIFY DELETE - Ensure data is gone + let get_deleted_result = context.client.get_table_data( + context.profile_name.clone(), + context.table_name.clone(), + created_id, + ).await; + + assert!(get_deleted_result.is_err(), "Should not be able to GET deleted record"); + if let Some(status) = get_deleted_result.unwrap_err().downcast_ref::() { + assert_eq!(status.code(), tonic::Code::NotFound); + } + println!("Workflow: Verified record deletion"); + + println!("Complete CRUD workflow test successful for ID {}", created_id); +} + +// ======================================================================== +// FORM STATE INTEGRATION TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_form_state_integration(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + // Create a form state + let mut form_state = FormState::new( + context.profile_name.clone(), + context.table_name.clone(), + vec![], // columns would be populated in real use + ); + + // Test count update + let count_result = context.client.get_table_data_count( + context.profile_name.clone(), + context.table_name.clone(), + ).await; + + if let Ok(count) = count_result { + form_state.total_count = count; + assert_eq!(form_state.total_count, count, "Form state count should match backend"); + println!("Form state updated with count: {}", form_state.total_count); + } + + // Create a test record for form state testing + let test_data = context.create_test_form_data(); + if let Ok(post_response) = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + test_data, + ).await { + let created_id = post_response.inserted_id; + + // Test form state update from backend response + if let Ok(get_response) = context.client.get_table_data( + context.profile_name.clone(), + context.table_name.clone(), + created_id, + ).await { + form_state.update_from_response(&get_response.data, created_id as u64); + assert_eq!(form_state.current_position, created_id as u64, "Form state position should match"); + assert!(!form_state.has_unsaved_changes(), "Form state should not have unsaved changes after update"); + println!("Form state successfully updated from backend data"); + } + } else { + println!("Could not create test record for form state test"); + } +} + +// ======================================================================== +// CONCURRENT OPERATIONS TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_concurrent_post_operations(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + // Create multiple concurrent POST operations using tokio::spawn + let mut handles = Vec::new(); + + for i in 0..5 { + let context_clone = context.clone(); + let handle = tokio::spawn(async move { + let mut data = context_clone.create_test_form_data(); + data.insert("firma".to_string(), create_string_value(&format!("Concurrent Company {}", i))); + data.insert("kz".to_string(), create_string_value(&format!("CONC{}", i))); + + let mut client = context_clone.client; + client.post_table_data( + context_clone.profile_name.clone(), + context_clone.table_name.clone(), + data, + ).await + }); + handles.push(handle); + } + + // Wait for all tasks to complete + let mut success_count = 0; + for (i, handle) in handles.into_iter().enumerate() { + match handle.await { + Ok(Ok(response)) => { + assert!(response.success, "Concurrent POST {} should succeed", i); + success_count += 1; + } + Ok(Err(_)) => { + println!("Concurrent POST {} failed (may be expected if backend issues)", i); + } + Err(e) => { + println!("Concurrent task {} panicked: {}", i, e); + } + } + } + + println!("Concurrent operations: {}/{} succeeded", success_count, 5); + assert!(success_count > 0, "At least some concurrent operations should succeed"); +} + +// ======================================================================== +// PERFORMANCE AND STRESS TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_rapid_sequential_operations(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let start_time = std::time::Instant::now(); + let operation_count = 10; + let mut successful_operations = 0; + + for i in 0..operation_count { + let mut data = context.create_test_form_data(); + data.insert("firma".to_string(), create_string_value(&format!("Rapid Company {}", i))); + data.insert("kz".to_string(), create_string_value(&format!("RAP{}", i))); + + if let Ok(response) = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + data, + ).await { + assert!(response.success, "Rapid operation {} should succeed", i); + successful_operations += 1; + } + } + + let duration = start_time.elapsed(); + println!("{} rapid operations took: {:?}", operation_count, duration); + println!("Success rate: {}/{}", successful_operations, operation_count); + + assert!(successful_operations > 0, "At least some rapid operations should succeed"); + assert!(duration.as_secs() < 30, "Rapid operations should complete in reasonable time"); +} + +// ======================================================================== +// CONNECTION AND CLIENT TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_grpc_client_connection() { + if std::env::var("SKIP_BACKEND_TESTS").is_ok() { + println!("Connection test skipped due to SKIP_BACKEND_TESTS"); + return; + } + + let client_result = GrpcClient::new().await; + match client_result { + Ok(_) => println!("gRPC client connection test passed"), + Err(e) => { + println!("gRPC client connection failed (expected if backend not running): {}", e); + // Don't panic - this is expected when backend is not available + } + } +} + +#[rstest] +#[tokio::test] +async fn test_client_timeout_handling(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + // Test that operations complete within reasonable timeouts + let timeout_duration = Duration::from_secs(10); + + let count_result = timeout( + timeout_duration, + context.client.get_table_data_count( + context.profile_name.clone(), + context.table_name.clone(), + ) + ).await; + + match count_result { + Ok(Ok(count)) => { + println!("Count operation completed within timeout: {}", count); + } + Ok(Err(e)) => { + println!("Count operation failed: {}", e); + } + Err(_) => { + panic!("Count operation timed out after {:?}", timeout_duration); + } + } +} + +// ======================================================================== +// DATA EDGE CASES TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_special_characters_and_unicode(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let special_strings = vec![ + "José María González", + "Москва", + "北京市", + "🚀 Tech Company 🌟", + "Quote\"Test'Apostrophe", + "Price: $1,000.50 (50% off!)", + ]; + + for (i, test_string) in special_strings.iter().enumerate() { + let mut data = HashMap::new(); + data.insert("firma".to_string(), create_string_value(test_string)); + data.insert("kz".to_string(), create_string_value(&format!("UNI{}", i))); + + let result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + data, + ).await; + + if let Ok(response) = result { + assert!(response.success, "Should handle special characters: '{}'", test_string); + println!("Successfully handled special string: '{}'", test_string); + } else { + println!("Failed to handle special string: '{}' (may be expected)", test_string); + } + } +} + +#[rstest] +#[tokio::test] +async fn test_null_and_empty_values(#[future] form_test_context: FormTestContext) { + let mut context = form_test_context.await; + skip_if_backend_unavailable!(); + + let mut data = HashMap::new(); + data.insert("firma".to_string(), create_string_value("Null Test Company")); + data.insert("telefon".to_string(), create_null_value()); + data.insert("email".to_string(), create_string_value("")); + data.insert("ulica".to_string(), create_string_value(" ")); // Whitespace only + + let result = context.client.post_table_data( + context.profile_name.clone(), + context.table_name.clone(), + data, + ).await; + + if let Ok(response) = result { + assert!(response.success, "Should handle null and empty values"); + println!("Successfully handled null and empty values"); + } else { + println!("Failed to handle null and empty values (may be expected based on validation)"); + } +} + diff --git a/client/tests/form/requests/mod.rs b/client/tests/form/requests/mod.rs new file mode 100644 index 0000000..89aa474 --- /dev/null +++ b/client/tests/form/requests/mod.rs @@ -0,0 +1 @@ +pub mod form_request_tests; diff --git a/client/tests/mod.rs b/client/tests/mod.rs index b15a781..8d78128 100644 --- a/client/tests/mod.rs +++ b/client/tests/mod.rs @@ -1,3 +1,3 @@ // tests/mod.rs -pub mod form_tests; +pub mod form;