diff --git a/server/tests/tables_data/handlers/put_table_data_test.rs b/server/tests/tables_data/handlers/put_table_data_test.rs index b6e9b0c..30812a0 100644 --- a/server/tests/tables_data/handlers/put_table_data_test.rs +++ b/server/tests/tables_data/handlers/put_table_data_test.rs @@ -5,7 +5,7 @@ use sqlx::{PgPool, Row}; use std::collections::HashMap; use prost_types::{value::Kind, Value}; use common::proto::multieko2::table_definition::{ - PostTableDefinitionRequest, ColumnDefinition as TableColumnDefinition, + PostTableDefinitionRequest, ColumnDefinition as TableColumnDefinition, TableLink, }; use common::proto::multieko2::tables_data::{ PostTableDataRequest, PutTableDataRequest, @@ -20,11 +20,12 @@ use tokio::sync::mpsc; use server::indexer::IndexCommand; use rand::Rng; use rand::distr::Alphanumeric; +use futures; // ========= Test Helpers ========= fn generate_unique_id() -> String { - rand::thread_rng() + rand::rng() .sample_iter(&Alphanumeric) .take(8) .map(char::from) @@ -66,11 +67,6 @@ async fn create_adresar_table( Ok(()) } -async fn create_test_indexer_channel( -) -> (mpsc::Sender, mpsc::Receiver) { - mpsc::channel(100) -} - // Helper to create a record and return its ID for tests async fn create_initial_record( context: &TestContext, @@ -538,3 +534,6 @@ async fn test_update_boolean_system_column_validation( .unwrap(); assert!(deleted); } + +include!("put_table_data_test2.rs"); +include!("put_table_data_test3.rs"); diff --git a/server/tests/tables_data/handlers/put_table_data_test2.rs b/server/tests/tables_data/handlers/put_table_data_test2.rs new file mode 100644 index 0000000..73b76b0 --- /dev/null +++ b/server/tests/tables_data/handlers/put_table_data_test2.rs @@ -0,0 +1,833 @@ +// tests/tables_data/handlers/put_table_data_test2.rs + +// ======================================================================== +// ADDITIONAL HELPER FUNCTIONS FOR COMPREHENSIVE PUT TESTS +// ======================================================================== + +// Additional imports needed for these tests +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use std::str::FromStr; + +fn create_string_value(s: &str) -> Value { + Value { kind: Some(Kind::StringValue(s.to_string())) } +} + +fn create_number_value(n: f64) -> Value { + Value { kind: Some(Kind::NumberValue(n)) } +} + +fn create_bool_value(b: bool) -> Value { + Value { kind: Some(Kind::BoolValue(b)) } +} + +fn create_null_value() -> Value { + Value { kind: Some(Kind::NullValue(0)) } +} + +// Helper to create a table with various data types for comprehensive testing +async fn create_data_type_test_table( + pool: &PgPool, + table_name: &str, + profile_name: &str +) -> Result<(), tonic::Status> { + let table_def_request = PostTableDefinitionRequest { + profile_name: profile_name.into(), + table_name: table_name.into(), + columns: vec![ + TableColumnDefinition { name: "my_text".into(), field_type: "text".into() }, + TableColumnDefinition { name: "my_bool".into(), field_type: "boolean".into() }, + TableColumnDefinition { name: "my_timestamp".into(), field_type: "timestamptz".into() }, + TableColumnDefinition { name: "my_bigint".into(), field_type: "integer".into() }, + TableColumnDefinition { name: "my_money".into(), field_type: "decimal(19,4)".into() }, + TableColumnDefinition { name: "my_decimal".into(), field_type: "decimal(10,2)".into() }, + ], + indexes: vec![], + links: vec![], + }; + post_table_definition(pool, table_def_request).await?; + Ok(()) +} + +// Helper to create financial table for decimal tests +async fn create_financial_table( + pool: &PgPool, + table_name: &str, + profile_name: &str, +) -> Result<(), tonic::Status> { + let table_def_request = PostTableDefinitionRequest { + profile_name: profile_name.into(), + table_name: table_name.into(), + columns: vec![ + TableColumnDefinition { name: "product_name".into(), field_type: "text".into() }, + TableColumnDefinition { name: "price".into(), field_type: "decimal(19, 4)".into() }, + TableColumnDefinition { name: "rate".into(), field_type: "decimal(10, 5)".into() }, + ], + indexes: vec![], + links: vec![], + }; + post_table_definition(pool, table_def_request).await?; + Ok(()) +} + +// ======================================================================== +// TEST CONTEXTS FOR DIFFERENT TABLE TYPES +// ======================================================================== + +#[derive(Clone)] +struct DataTypeTestContext { + pool: PgPool, + profile_name: String, + table_name: String, + indexer_tx: mpsc::Sender, +} + +#[derive(Clone)] +struct FinancialTestContext { + pool: PgPool, + profile_name: String, + table_name: String, + indexer_tx: mpsc::Sender, +} + +#[fixture] +async fn data_type_test_context() -> DataTypeTestContext { + let pool = setup_test_db().await; + let unique_id = generate_unique_id(); + let profile_name = format!("dtype_profile_{}", unique_id); + let table_name = format!("dtype_table_{}", unique_id); + + create_data_type_test_table(&pool, &table_name, &profile_name).await + .expect("Failed to create data type test table"); + + let (tx, mut rx) = mpsc::channel(100); + tokio::spawn(async move { while rx.recv().await.is_some() {} }); + + DataTypeTestContext { pool, profile_name, table_name, indexer_tx: tx } +} + +#[fixture] +async fn financial_test_context() -> FinancialTestContext { + let pool = setup_test_db().await; + let unique_id = generate_unique_id(); + let profile_name = format!("financial_profile_{}", unique_id); + let table_name = format!("invoices_{}", unique_id); + + create_financial_table(&pool, &table_name, &profile_name).await + .expect("Failed to create financial test table"); + + let (tx, mut rx) = mpsc::channel(100); + tokio::spawn(async move { while rx.recv().await.is_some() {} }); + + FinancialTestContext { pool, profile_name, table_name, indexer_tx: tx } +} + +// Helper to create initial record for data type tests +async fn create_initial_data_type_record(context: &DataTypeTestContext) -> i64 { + let mut initial_data = HashMap::new(); + initial_data.insert("my_text".to_string(), string_to_proto_value("Initial Text")); + initial_data.insert("my_bool".to_string(), bool_to_proto_value(false)); + initial_data.insert("my_timestamp".to_string(), string_to_proto_value("2024-01-01T12:00:00Z")); + initial_data.insert("my_bigint".to_string(), Value { kind: Some(Kind::NumberValue(100.0)) }); + initial_data.insert("my_money".to_string(), string_to_proto_value("100.0000")); + initial_data.insert("my_decimal".to_string(), string_to_proto_value("50.00")); + + let request = PostTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + data: initial_data, + }; + let response = post_table_data(&context.pool, request, &context.indexer_tx) + .await + .expect("Setup: Failed to create initial record"); + response.inserted_id +} + +// Helper to create initial record for financial tests +async fn create_initial_financial_record(context: &FinancialTestContext) -> i64 { + let mut initial_data = HashMap::new(); + initial_data.insert("product_name".to_string(), string_to_proto_value("Initial Product")); + initial_data.insert("price".to_string(), string_to_proto_value("100.0000")); + initial_data.insert("rate".to_string(), string_to_proto_value("1.00000")); + + let request = PostTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + data: initial_data, + }; + let response = post_table_data(&context.pool, request, &context.indexer_tx) + .await + .expect("Setup: Failed to create initial record"); + response.inserted_id +} + +// ======================================================================== +// UNICODE AND SPECIAL CHARACTER TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_update_unicode_special_characters_comprehensive( + #[future] test_context: TestContext, +) { + let context = test_context.await; + + let special_strings = vec![ + "José María González", // Accented characters + "Москва", // Cyrillic + "北京市", // Chinese + "🚀 Tech Company 🌟", // Emoji + "Line\nBreak\tTab", // Control characters + "Quote\"Test'Apostrophe", // Quotes + "SQL'; DROP TABLE test; --", // SQL injection attempt + "Price: $1,000.50 (50% off!)", // Special symbols + ]; + + for (i, test_string) in special_strings.into_iter().enumerate() { + let record_id = create_initial_record( + &context, + HashMap::from([("firma".to_string(), string_to_proto_value("Original"))]), + ).await; + + let mut update_data = HashMap::new(); + update_data.insert("firma".to_string(), string_to_proto_value(test_string)); + update_data.insert("kz".to_string(), string_to_proto_value(&format!("TEST{}", i))); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data: update_data, + }; + + let response = put_table_data(&context.pool, request, &context.indexer_tx) + .await + .unwrap(); + assert!(response.success, "Failed for string: '{}'", test_string); + + // Verify the data was updated correctly + let query = format!( + r#"SELECT firma FROM "{}"."{}" WHERE id = $1"#, + context.profile_name, context.table_name + ); + let stored_firma: String = sqlx::query_scalar(&query) + .bind(record_id) + .fetch_one(&context.pool) + .await + .unwrap(); + assert_eq!(stored_firma, test_string.trim()); + } +} + +#[rstest] +#[tokio::test] +async fn test_update_field_length_boundaries(#[future] test_context: TestContext) { + let context = test_context.await; + + let length_test_cases = vec![ + ("1234567890123456", true), // 16 chars - should fail + ("123456789012345", false), // 15 chars - should pass + ("", false), // Empty - should pass (becomes NULL) + ("1", false), // Single char - should pass + ]; + + for (test_string, should_fail) in length_test_cases { + let record_id = create_initial_record( + &context, + HashMap::from([("telefon".to_string(), string_to_proto_value("555-1234"))]), + ).await; + + let mut update_data = HashMap::new(); + update_data.insert("firma".to_string(), string_to_proto_value("Length Test Company")); + update_data.insert("telefon".to_string(), string_to_proto_value(test_string)); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data: update_data, + }; + + let result = put_table_data(&context.pool, request, &context.indexer_tx).await; + + if should_fail { + assert!(result.is_err(), "Should fail for telefon length: {}", test_string.len()); + if let Err(err) = result { + assert_eq!(err.code(), tonic::Code::Internal); + assert!(err.message().contains("Value too long for telefon")); + } + } else { + assert!(result.is_ok(), "Should succeed for telefon length: {}", test_string.len()); + } + } +} + +// ======================================================================== +// LARGE TEXT FIELD TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_update_large_text_fields(#[future] test_context: TestContext) { + let context = test_context.await; + + let sizes = vec![1000, 5000, 10000]; + + for size in sizes { + let large_text = "A".repeat(size); + let record_id = create_initial_record( + &context, + HashMap::from([("firma".to_string(), string_to_proto_value("Original"))]), + ).await; + + let mut update_data = HashMap::new(); + update_data.insert("firma".to_string(), string_to_proto_value(&large_text)); + update_data.insert("ulica".to_string(), string_to_proto_value(&format!("Street with {} chars", size))); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data: update_data, + }; + + let response = put_table_data(&context.pool, request, &context.indexer_tx) + .await + .unwrap(); + assert!(response.success, "Failed for size: {}", size); + + // Verify the large text was stored correctly + let query = format!( + r#"SELECT firma FROM "{}"."{}" WHERE id = $1"#, + context.profile_name, context.table_name + ); + let stored_firma: String = sqlx::query_scalar(&query) + .bind(record_id) + .fetch_one(&context.pool) + .await + .unwrap(); + assert_eq!(stored_firma.len(), size); + assert_eq!(stored_firma, large_text); + } +} + +// ======================================================================== +// DATA TYPE VALIDATION TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_update_correct_data_types_success( + #[future] data_type_test_context: DataTypeTestContext, +) { + let context = data_type_test_context.await; + let record_id = create_initial_data_type_record(&context).await; + + let mut data = HashMap::new(); + data.insert("my_text".into(), create_string_value("Updated String")); + data.insert("my_bool".into(), create_bool_value(true)); + data.insert("my_timestamp".into(), create_string_value("2024-06-15T15:30:00Z")); + data.insert("my_bigint".into(), create_number_value(142.0)); + data.insert("my_money".into(), create_string_value("223.45")); + data.insert("my_decimal".into(), create_string_value("899.99")); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data, + }; + + let response = put_table_data(&context.pool, request, &context.indexer_tx) + .await + .unwrap(); + assert!(response.success); + assert_eq!(response.updated_id, record_id); + + // Verify data was updated correctly + let query = format!( + r#"SELECT my_text, my_bool, my_bigint FROM "{}"."{}" WHERE id = $1"#, + context.profile_name, context.table_name + ); + let row = sqlx::query(&query) + .bind(record_id) + .fetch_one(&context.pool) + .await + .unwrap(); + + let stored_text: String = row.get("my_text"); + let stored_bool: bool = row.get("my_bool"); + let stored_bigint: i32 = row.get("my_bigint"); + + assert_eq!(stored_text, "Updated String"); + assert_eq!(stored_bool, true); + assert_eq!(stored_bigint, 142); +} + +#[rstest] +#[tokio::test] +async fn test_update_type_mismatch_string_for_boolean( + #[future] data_type_test_context: DataTypeTestContext, +) { + let context = data_type_test_context.await; + let record_id = create_initial_data_type_record(&context).await; + + let mut data = HashMap::new(); + data.insert("my_text".into(), create_string_value("Updated field")); + data.insert("my_bool".into(), create_string_value("true")); // String instead of boolean + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data, + }; + + let result = put_table_data(&context.pool, request, &context.indexer_tx).await; + assert!(result.is_err()); + + if let Err(err) = result { + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("Expected boolean for column 'my_bool'")); + } +} + +#[rstest] +#[tokio::test] +async fn test_update_invalid_timestamp_format( + #[future] data_type_test_context: DataTypeTestContext, +) { + let context = data_type_test_context.await; + let record_id = create_initial_data_type_record(&context).await; + + let mut data = HashMap::new(); + data.insert("my_text".into(), create_string_value("Updated field")); + data.insert("my_timestamp".into(), create_string_value("not-a-date")); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data, + }; + + let result = put_table_data(&context.pool, request, &context.indexer_tx).await; + assert!(result.is_err()); + + if let Err(err) = result { + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("Invalid timestamp for my_timestamp")); + } +} + +#[rstest] +#[tokio::test] +async fn test_update_float_for_integer_field( + #[future] data_type_test_context: DataTypeTestContext, +) { + let context = data_type_test_context.await; + let record_id = create_initial_data_type_record(&context).await; + + let mut data = HashMap::new(); + data.insert("my_text".into(), create_string_value("Updated field")); + data.insert("my_bigint".into(), create_number_value(123.45)); // Float for integer field + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data, + }; + + let result = put_table_data(&context.pool, request, &context.indexer_tx).await; + assert!(result.is_err()); + + if let Err(err) = result { + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("Expected integer for column 'my_bigint', but got a float")); + } +} + +// ======================================================================== +// DECIMAL/NUMERIC DATA TYPE TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_update_valid_decimal_string( + #[future] financial_test_context: FinancialTestContext, +) { + let context = financial_test_context.await; + let record_id = create_initial_financial_record(&context).await; + + let mut data = HashMap::new(); + data.insert("product_name".into(), create_string_value("Updated Laptop")); + data.insert("price".into(), create_string_value("1599.99")); + data.insert("rate".into(), create_string_value("-0.54321")); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data, + }; + + let response = put_table_data(&context.pool, request, &context.indexer_tx) + .await + .unwrap(); + assert!(response.success); + + let query = format!( + r#"SELECT price, rate FROM "{}"."{}" WHERE id = $1"#, + context.profile_name, context.table_name + ); + let row = sqlx::query(&query) + .bind(record_id) + .fetch_one(&context.pool) + .await + .unwrap(); + + let price: Decimal = row.get("price"); + let rate: Decimal = row.get("rate"); + + assert_eq!(price, Decimal::from_str("1599.99").unwrap()); + assert_eq!(rate, Decimal::from_str("-0.54321").unwrap()); +} + +#[rstest] +#[tokio::test] +async fn test_update_decimal_from_number_fails( + #[future] financial_test_context: FinancialTestContext, +) { + let context = financial_test_context.await; + let record_id = create_initial_financial_record(&context).await; + + let mut data = HashMap::new(); + data.insert("product_name".into(), create_string_value("Updated Mouse")); + data.insert("price".into(), create_number_value(85.50)); // Number instead of string + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data, + }; + + let result = put_table_data(&context.pool, request, &context.indexer_tx).await; + assert!(result.is_err()); + + let status = result.unwrap_err(); + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!(status.message().contains("Expected a string representation for decimal column 'price'")); +} + +#[rstest] +#[tokio::test] +async fn test_update_invalid_decimal_string_fails( + #[future] financial_test_context: FinancialTestContext, +) { + let context = financial_test_context.await; + let record_id = create_initial_financial_record(&context).await; + + let mut data = HashMap::new(); + data.insert("product_name".into(), create_string_value("Bad Data Update")); + data.insert("price".into(), create_string_value("not-a-number")); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data, + }; + + let result = put_table_data(&context.pool, request, &context.indexer_tx).await; + assert!(result.is_err()); + let status = result.unwrap_err(); + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!(status.message().contains("Invalid decimal string format for column 'price'")); +} + +// ======================================================================== +// INTEGER BOUNDARY TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_update_integer_boundary_values( + #[future] data_type_test_context: DataTypeTestContext, +) { + let context = data_type_test_context.await; + + let boundary_values = vec![ + (0.0, "zero"), + (1.0, "one"), + (-1.0, "negative one"), + (2147483647.0, "i32::MAX"), + (-2147483648.0, "i32::MIN"), + (2147483646.0, "i32::MAX - 1"), + (-2147483647.0, "i32::MIN + 1"), + ]; + + for (value, description) in boundary_values { + let record_id = create_initial_data_type_record(&context).await; + + let mut data = HashMap::new(); + data.insert("my_text".into(), create_string_value(&format!("Boundary test: {}", description))); + data.insert("my_bigint".into(), create_number_value(value)); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data, + }; + + let result = put_table_data(&context.pool, request, &context.indexer_tx).await; + assert!(result.is_ok(), "Failed for boundary value: {} ({})", value, description); + } +} + +#[rstest] +#[tokio::test] +async fn test_update_integer_overflow_rejection( + #[future] data_type_test_context: DataTypeTestContext, +) { + let context = data_type_test_context.await; + + let overflow_values = vec![ + (2147483648.0, "i32::MAX + 1"), + (-2147483649.0, "i32::MIN - 1"), + (3000000000.0, "3 billion"), + (-3000000000.0, "negative 3 billion"), + ]; + + for (value, description) in overflow_values { + let record_id = create_initial_data_type_record(&context).await; + + let mut data = HashMap::new(); + data.insert("my_text".into(), create_string_value(&format!("Overflow test: {}", description))); + data.insert("my_bigint".into(), create_number_value(value)); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data, + }; + + let result = put_table_data(&context.pool, request, &context.indexer_tx).await; + assert!(result.is_err(), "Should have failed for overflow value {}: {}", value, description); + + if let Err(err) = result { + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert!(err.message().contains("Integer value out of range for INTEGER column")); + } + } +} + +// ======================================================================== +// PERFORMANCE AND STRESS TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_update_rapid_sequential_operations(#[future] test_context: TestContext) { + let context = test_context.await; + + let start_time = std::time::Instant::now(); + + // Create initial records + let mut record_ids = Vec::new(); + for i in 0..50 { + let record_id = create_initial_record( + &context, + HashMap::from([("firma".to_string(), string_to_proto_value(&format!("Original Company {}", i)))]), + ).await; + record_ids.push(record_id); + } + + // Perform rapid sequential updates + for (i, record_id) in record_ids.iter().enumerate() { + let mut update_data = HashMap::new(); + update_data.insert("firma".to_string(), string_to_proto_value(&format!("Updated Rapid Company {}", i))); + update_data.insert("kz".to_string(), string_to_proto_value(&format!("RAP{}", i))); + update_data.insert("telefon".to_string(), string_to_proto_value(&format!("+421{:09}", i))); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: *record_id, + data: update_data, + }; + + let response = put_table_data(&context.pool, request, &context.indexer_tx) + .await + .unwrap(); + assert!(response.success, "Rapid update {} should succeed", i); + } + + let duration = start_time.elapsed(); + println!("50 rapid updates took: {:?}", duration); + + // Verify all records were updated + let query = format!( + r#"SELECT COUNT(*) FROM "{}"."{}" WHERE firma LIKE 'Updated Rapid Company%'"#, + context.profile_name, context.table_name + ); + + let count: i64 = sqlx::query_scalar(&query) + .fetch_one(&context.pool) + .await + .unwrap(); + assert_eq!(count, 50); +} + +// ======================================================================== +// ERROR SCENARIO TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_update_nonexistent_profile_error(#[future] test_context: TestContext) { + let context = test_context.await; + let record_id = create_initial_record( + &context, + HashMap::from([("firma".to_string(), string_to_proto_value("Test Company"))]), + ).await; + + let mut update_data = HashMap::new(); + update_data.insert("firma".to_string(), string_to_proto_value("Updated Company")); + + let invalid_request = PutTableDataRequest { + profile_name: "nonexistent_profile".into(), + table_name: context.table_name.clone(), + id: record_id, + data: update_data, + }; + + let result = put_table_data(&context.pool, invalid_request, &context.indexer_tx).await; + assert!(result.is_err()); + + if let Err(err) = result { + assert_eq!(err.code(), tonic::Code::NotFound); + assert!(err.message().contains("Profile not found")); + } +} + +#[rstest] +#[tokio::test] +async fn test_update_nonexistent_table_error(#[future] test_context: TestContext) { + let context = test_context.await; + let record_id = create_initial_record( + &context, + HashMap::from([("firma".to_string(), string_to_proto_value("Test Company"))]), + ).await; + + let mut update_data = HashMap::new(); + update_data.insert("firma".to_string(), string_to_proto_value("Updated Company")); + + let invalid_request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: "nonexistent_table".into(), + id: record_id, + data: update_data, + }; + + let result = put_table_data(&context.pool, invalid_request, &context.indexer_tx).await; + assert!(result.is_err()); + + if let Err(err) = result { + assert_eq!(err.code(), tonic::Code::NotFound); + assert!(err.message().contains("Table not found")); + } +} + +// ======================================================================== +// NULL VALUE HANDLING TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_update_null_values_for_all_types( + #[future] data_type_test_context: DataTypeTestContext, +) { + let context = data_type_test_context.await; + let record_id = create_initial_data_type_record(&context).await; + + let mut data = HashMap::new(); + data.insert("my_text".into(), create_string_value("Updated but keep nulls")); + data.insert("my_bool".into(), create_null_value()); + data.insert("my_timestamp".into(), create_null_value()); + data.insert("my_bigint".into(), create_null_value()); + data.insert("my_money".into(), create_null_value()); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data, + }; + + let response = put_table_data(&context.pool, request, &context.indexer_tx) + .await + .unwrap(); + assert!(response.success); + + // Verify nulls were stored correctly + let query = format!( + r#"SELECT my_bool, my_timestamp, my_bigint FROM "{}"."{}" WHERE id = $1"#, + context.profile_name, context.table_name + ); + + let row = sqlx::query(&query) + .bind(record_id) + .fetch_one(&context.pool) + .await + .unwrap(); + + let stored_bool: Option = row.get("my_bool"); + let stored_timestamp: Option> = row.get("my_timestamp"); + let stored_bigint: Option = row.get("my_bigint"); + + assert!(stored_bool.is_none()); + assert!(stored_timestamp.is_none()); + assert!(stored_bigint.is_none()); +} + +// ======================================================================== +// VALID TIMESTAMP FORMAT TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_update_valid_timestamp_formats( + #[future] data_type_test_context: DataTypeTestContext, +) { + let context = data_type_test_context.await; + + let valid_timestamps = vec![ + "2024-01-15T10:30:00Z", + "2024-01-15T10:30:00+00:00", + "2024-01-15T10:30:00.123Z", + "2024-12-31T23:59:59Z", + "1970-01-01T00:00:00Z", // Unix epoch + ]; + + for (i, timestamp) in valid_timestamps.into_iter().enumerate() { + let record_id = create_initial_data_type_record(&context).await; + + let mut data = HashMap::new(); + data.insert("my_text".into(), create_string_value(&format!("Timestamp test {}", i))); + data.insert("my_timestamp".into(), create_string_value(timestamp)); + + let request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + id: record_id, + data, + }; + + let result = put_table_data(&context.pool, request, &context.indexer_tx).await; + assert!(result.is_ok(), "Failed for timestamp: {}", timestamp); + } +} diff --git a/server/tests/tables_data/handlers/put_table_data_test3.rs b/server/tests/tables_data/handlers/put_table_data_test3.rs new file mode 100644 index 0000000..0e4e1d5 --- /dev/null +++ b/server/tests/tables_data/handlers/put_table_data_test3.rs @@ -0,0 +1,490 @@ +// tests/tables_data/handlers/put_table_data_test3.rs + +// ======================================================================== +// ADDITIONAL HELPER FUNCTIONS FOR COMPREHENSIVE PUT TEST 3 +// ======================================================================== + +use rust_decimal_macros::dec; + +// Note: Helper functions like create_string_value, create_number_value, etc. +// are already defined in the main test file, so we don't redefine them here. + +// ======================================================================== +// CONTEXTS FOR ADVANCED TESTING +// ======================================================================== + +#[derive(Clone)] +struct AdvancedDataTypeTestContext { + pool: PgPool, + profile_name: String, + table_name: String, + indexer_tx: mpsc::Sender, +} + +#[derive(Clone)] +struct ForeignKeyUpdateTestContext { + pool: PgPool, + profile_name: String, + category_table: String, + product_table: String, + order_table: String, + indexer_tx: mpsc::Sender, +} + +#[derive(Clone)] +struct IntegerRobustnessTestContext { + pool: PgPool, + profile_name: String, + mixed_integer_table: String, + bigint_only_table: String, + integer_only_table: String, + indexer_tx: mpsc::Sender, +} + +#[derive(Clone)] +struct FinancialUpdateTestContext { + pool: PgPool, + profile_name: String, + table_name: String, + indexer_tx: mpsc::Sender, +} + +// ======================================================================== +// TABLE CREATION HELPERS +// ======================================================================== + +async fn create_advanced_data_type_table( + pool: &PgPool, + table_name: &str, + profile_name: &str +) -> Result<(), tonic::Status> { + let table_def_request = PostTableDefinitionRequest { + profile_name: profile_name.into(), + table_name: table_name.into(), + columns: vec![ + TableColumnDefinition { name: "my_text".into(), field_type: "text".into() }, + TableColumnDefinition { name: "my_bool".into(), field_type: "boolean".into() }, + TableColumnDefinition { name: "my_timestamp".into(), field_type: "timestamptz".into() }, + TableColumnDefinition { name: "my_bigint".into(), field_type: "integer".into() }, + TableColumnDefinition { name: "my_money".into(), field_type: "decimal(19,4)".into() }, + TableColumnDefinition { name: "my_date".into(), field_type: "date".into() }, + TableColumnDefinition { name: "my_decimal".into(), field_type: "decimal(10,2)".into() }, + TableColumnDefinition { name: "my_real_bigint".into(), field_type: "biginteger".into() }, + ], + indexes: vec![], + links: vec![], + }; + + post_table_definition(pool, table_def_request).await?; + Ok(()) +} + +async fn create_foreign_key_test_tables( + pool: &PgPool, + profile_name: &str, + category_table: &str, + product_table: &str, + order_table: &str +) -> Result<(), tonic::Status> { + // Create category table first (no dependencies) + let category_def = PostTableDefinitionRequest { + profile_name: profile_name.into(), + table_name: category_table.into(), + columns: vec![ + TableColumnDefinition { name: "name".into(), field_type: "text".into() }, + TableColumnDefinition { name: "description".into(), field_type: "text".into() }, + ], + indexes: vec![], + links: vec![], + }; + post_table_definition(pool, category_def).await?; + + // Create product table with required link to category + let product_def = PostTableDefinitionRequest { + profile_name: profile_name.into(), + table_name: product_table.into(), + columns: vec![ + TableColumnDefinition { name: "name".into(), field_type: "text".into() }, + TableColumnDefinition { name: "price".into(), field_type: "decimal(10,2)".into() }, + ], + indexes: vec![], + links: vec![ + TableLink { linked_table_name: category_table.into(), required: true }, + ], + }; + post_table_definition(pool, product_def).await?; + + // Create order table with required link to product and optional link to category + let order_def = PostTableDefinitionRequest { + profile_name: profile_name.into(), + table_name: order_table.into(), + columns: vec![ + TableColumnDefinition { name: "quantity".into(), field_type: "integer".into() }, + TableColumnDefinition { name: "notes".into(), field_type: "text".into() }, + ], + indexes: vec![], + links: vec![ + TableLink { linked_table_name: product_table.into(), required: true }, + TableLink { linked_table_name: category_table.into(), required: false }, + ], + }; + post_table_definition(pool, order_def).await?; + + Ok(()) +} + +async fn create_integer_robustness_tables( + pool: &PgPool, + profile_name: &str, + mixed_table: &str, + bigint_table: &str, + integer_table: &str +) -> Result<(), tonic::Status> { + // Table with both INTEGER and BIGINT columns + let mixed_def = PostTableDefinitionRequest { + profile_name: profile_name.into(), + table_name: mixed_table.into(), + columns: vec![ + TableColumnDefinition { name: "name".into(), field_type: "text".into() }, + TableColumnDefinition { name: "small_int".into(), field_type: "integer".into() }, + TableColumnDefinition { name: "big_int".into(), field_type: "biginteger".into() }, + TableColumnDefinition { name: "another_int".into(), field_type: "int".into() }, + TableColumnDefinition { name: "another_bigint".into(), field_type: "bigint".into() }, + ], + indexes: vec![], + links: vec![], + }; + post_table_definition(pool, mixed_def).await?; + + // Table with only BIGINT columns + let bigint_def = PostTableDefinitionRequest { + profile_name: profile_name.into(), + table_name: bigint_table.into(), + columns: vec![ + TableColumnDefinition { name: "name".into(), field_type: "text".into() }, + TableColumnDefinition { name: "value1".into(), field_type: "biginteger".into() }, + TableColumnDefinition { name: "value2".into(), field_type: "bigint".into() }, + ], + indexes: vec![], + links: vec![], + }; + post_table_definition(pool, bigint_def).await?; + + // Table with only INTEGER columns + let integer_def = PostTableDefinitionRequest { + profile_name: profile_name.into(), + table_name: integer_table.into(), + columns: vec![ + TableColumnDefinition { name: "name".into(), field_type: "text".into() }, + TableColumnDefinition { name: "value1".into(), field_type: "integer".into() }, + TableColumnDefinition { name: "value2".into(), field_type: "int".into() }, + ], + indexes: vec![], + links: vec![], + }; + post_table_definition(pool, integer_def).await?; + + Ok(()) +} + +async fn create_financial_update_table( + pool: &PgPool, + table_name: &str, + profile_name: &str, +) -> Result<(), tonic::Status> { + let table_def_request = PostTableDefinitionRequest { + profile_name: profile_name.into(), + table_name: table_name.into(), + columns: vec![ + TableColumnDefinition { name: "product_name".into(), field_type: "text".into() }, + TableColumnDefinition { name: "price".into(), field_type: "decimal(19, 4)".into() }, + TableColumnDefinition { name: "rate".into(), field_type: "decimal(10, 5)".into() }, + TableColumnDefinition { name: "discount".into(), field_type: "decimal(5, 3)".into() }, + ], + indexes: vec![], + links: vec![], + }; + + post_table_definition(pool, table_def_request).await?; + Ok(()) +} + +// ======================================================================== +// FIXTURES +// ======================================================================== + +#[fixture] +async fn advanced_data_type_test_context() -> AdvancedDataTypeTestContext { + let pool = setup_test_db().await; + let unique_id = generate_unique_id(); + let profile_name = format!("adv_dtype_profile_{}", unique_id); + let table_name = format!("adv_dtype_table_{}", unique_id); + + create_advanced_data_type_table(&pool, &table_name, &profile_name).await + .expect("Failed to create advanced data type test table"); + + let (tx, mut rx) = mpsc::channel(100); + tokio::spawn(async move { while rx.recv().await.is_some() {} }); + + AdvancedDataTypeTestContext { pool, profile_name, table_name, indexer_tx: tx } +} + +#[fixture] +async fn foreign_key_update_test_context() -> ForeignKeyUpdateTestContext { + let pool = setup_test_db().await; + let unique_id = generate_unique_id(); + let profile_name = format!("fk_update_profile_{}", unique_id); + let category_table = format!("category_upd_{}", unique_id); + let product_table = format!("product_upd_{}", unique_id); + let order_table = format!("order_upd_{}", unique_id); + + create_foreign_key_test_tables(&pool, &profile_name, &category_table, &product_table, &order_table).await + .expect("Failed to create foreign key test tables"); + + let (tx, mut rx) = mpsc::channel(100); + tokio::spawn(async move { while rx.recv().await.is_some() {} }); + + ForeignKeyUpdateTestContext { + pool, profile_name, category_table, product_table, order_table, indexer_tx: tx + } +} + +#[fixture] +async fn integer_robustness_test_context() -> IntegerRobustnessTestContext { + let pool = setup_test_db().await; + let unique_id = generate_unique_id(); + let profile_name = format!("int_robust_profile_{}", unique_id); + let mixed_table = format!("mixed_int_table_{}", unique_id); + let bigint_table = format!("bigint_table_{}", unique_id); + let integer_table = format!("integer_table_{}", unique_id); + + create_integer_robustness_tables(&pool, &profile_name, &mixed_table, &bigint_table, &integer_table).await + .expect("Failed to create integer robustness test tables"); + + let (tx, mut rx) = mpsc::channel(100); + tokio::spawn(async move { while rx.recv().await.is_some() {} }); + + IntegerRobustnessTestContext { + pool, profile_name, mixed_integer_table: mixed_table, + bigint_only_table: bigint_table, integer_only_table: integer_table, indexer_tx: tx + } +} + +#[fixture] +async fn financial_update_test_context() -> FinancialUpdateTestContext { + let pool = setup_test_db().await; + let unique_id = generate_unique_id(); + let profile_name = format!("financial_upd_profile_{}", unique_id); + let table_name = format!("invoices_upd_{}", unique_id); + + create_financial_update_table(&pool, &table_name, &profile_name).await + .expect("Failed to create financial update test table"); + + let (tx, mut rx) = mpsc::channel(100); + tokio::spawn(async move { while rx.recv().await.is_some() {} }); + + FinancialUpdateTestContext { pool, profile_name, table_name, indexer_tx: tx } +} + +// ======================================================================== +// HELPER FUNCTIONS FOR CREATING INITIAL RECORDS +// ======================================================================== + +async fn create_initial_advanced_record(context: &AdvancedDataTypeTestContext) -> i64 { + let mut initial_data = HashMap::new(); + initial_data.insert("my_text".to_string(), string_to_proto_value("Initial Text")); + initial_data.insert("my_bool".to_string(), bool_to_proto_value(false)); + initial_data.insert("my_timestamp".to_string(), string_to_proto_value("2024-01-01T12:00:00Z")); + initial_data.insert("my_bigint".to_string(), Value { kind: Some(Kind::NumberValue(100.0)) }); + initial_data.insert("my_money".to_string(), string_to_proto_value("100.0000")); + initial_data.insert("my_decimal".to_string(), string_to_proto_value("50.00")); + initial_data.insert("my_real_bigint".to_string(), Value { kind: Some(Kind::NumberValue(1000000000000.0)) }); + + let request = PostTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + data: initial_data, + }; + let response = post_table_data(&context.pool, request, &context.indexer_tx) + .await + .expect("Setup: Failed to create initial record"); + response.inserted_id +} + +// Fixed function name to avoid conflict - this version is for FinancialUpdateTestContext +async fn create_initial_financial_update_record(context: &FinancialUpdateTestContext) -> i64 { + let mut initial_data = HashMap::new(); + initial_data.insert("product_name".to_string(), string_to_proto_value("Initial Product")); + initial_data.insert("price".to_string(), string_to_proto_value("100.0000")); + initial_data.insert("rate".to_string(), string_to_proto_value("1.00000")); + initial_data.insert("discount".to_string(), string_to_proto_value("0.100")); + + let request = PostTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.table_name.clone(), + data: initial_data, + }; + let response = post_table_data(&context.pool, request, &context.indexer_tx) + .await + .expect("Setup: Failed to create initial financial record"); + response.inserted_id +} + +async fn create_initial_integer_record(context: &IntegerRobustnessTestContext, table_name: &str) -> i64 { + let mut initial_data = HashMap::new(); + initial_data.insert("name".to_string(), string_to_proto_value("Initial Record")); + + match table_name { + table if table.contains("mixed") => { + initial_data.insert("small_int".to_string(), Value { kind: Some(Kind::NumberValue(100.0)) }); + initial_data.insert("big_int".to_string(), Value { kind: Some(Kind::NumberValue(1000000000000.0)) }); + initial_data.insert("another_int".to_string(), Value { kind: Some(Kind::NumberValue(200.0)) }); + initial_data.insert("another_bigint".to_string(), Value { kind: Some(Kind::NumberValue(2000000000000.0)) }); + }, + table if table.contains("bigint") => { + initial_data.insert("value1".to_string(), Value { kind: Some(Kind::NumberValue(1000000000000.0)) }); + initial_data.insert("value2".to_string(), Value { kind: Some(Kind::NumberValue(2000000000000.0)) }); + }, + table if table.contains("integer") => { + initial_data.insert("value1".to_string(), Value { kind: Some(Kind::NumberValue(100.0)) }); + initial_data.insert("value2".to_string(), Value { kind: Some(Kind::NumberValue(200.0)) }); + }, + _ => {} + } + + let request = PostTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: table_name.to_string(), + data: initial_data, + }; + let response = post_table_data(&context.pool, request, &context.indexer_tx) + .await + .expect("Setup: Failed to create initial integer record"); + response.inserted_id +} + +// ======================================================================== +// FOREIGN KEY UPDATE TESTS +// ======================================================================== + +#[rstest] +#[tokio::test] +async fn test_update_valid_foreign_key_reference( + #[future] foreign_key_update_test_context: ForeignKeyUpdateTestContext, +) { + let context = foreign_key_update_test_context.await; + + // Create categories + let mut category1_data = HashMap::new(); + category1_data.insert("name".to_string(), string_to_proto_value("Electronics")); + let category1_request = PostTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.category_table.clone(), + data: category1_data, + }; + let category1_response = post_table_data(&context.pool, category1_request, &context.indexer_tx).await.unwrap(); + + let mut category2_data = HashMap::new(); + category2_data.insert("name".to_string(), string_to_proto_value("Books")); + let category2_request = PostTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.category_table.clone(), + data: category2_data, + }; + let category2_response = post_table_data(&context.pool, category2_request, &context.indexer_tx).await.unwrap(); + + // Create product with category1 + let mut product_data = HashMap::new(); + product_data.insert("name".to_string(), string_to_proto_value("Laptop")); + product_data.insert("price".to_string(), string_to_proto_value("999.99")); + product_data.insert(format!("{}_id", context.category_table), Value { kind: Some(Kind::NumberValue(category1_response.inserted_id as f64)) }); + + let product_request = PostTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.product_table.clone(), + data: product_data, + }; + let product_response = post_table_data(&context.pool, product_request, &context.indexer_tx).await.unwrap(); + + // Update product to reference category2 + let mut update_data = HashMap::new(); + update_data.insert("name".to_string(), string_to_proto_value("Programming Book")); + update_data.insert(format!("{}_id", context.category_table), Value { kind: Some(Kind::NumberValue(category2_response.inserted_id as f64)) }); + + let update_request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.product_table.clone(), + id: product_response.inserted_id, + data: update_data, + }; + + let result = put_table_data(&context.pool, update_request, &context.indexer_tx).await; + assert!(result.is_ok(), "Update with valid foreign key should succeed"); + + // Verify the foreign key was updated + let query = format!( + r#"SELECT name, "{}_id" FROM "{}"."{}" WHERE id = $1"#, + context.category_table, context.profile_name, context.product_table + ); + let row = sqlx::query(&query) + .bind(product_response.inserted_id) + .fetch_one(&context.pool) + .await + .unwrap(); + + let name: String = row.get("name"); + let category_id: i64 = row.get(format!("{}_id", context.category_table).as_str()); + + assert_eq!(name, "Programming Book"); + assert_eq!(category_id, category2_response.inserted_id); +} + +#[rstest] +#[tokio::test] +async fn test_update_nonexistent_foreign_key_reference( + #[future] foreign_key_update_test_context: ForeignKeyUpdateTestContext, +) { + let context = foreign_key_update_test_context.await; + + // Create category and product + let mut category_data = HashMap::new(); + category_data.insert("name".to_string(), string_to_proto_value("Electronics")); + let category_request = PostTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.category_table.clone(), + data: category_data, + }; + let category_response = post_table_data(&context.pool, category_request, &context.indexer_tx).await.unwrap(); + + let mut product_data = HashMap::new(); + product_data.insert("name".to_string(), string_to_proto_value("Laptop")); + product_data.insert("price".to_string(), string_to_proto_value("999.99")); + product_data.insert(format!("{}_id", context.category_table), Value { kind: Some(Kind::NumberValue(category_response.inserted_id as f64)) }); + + let product_request = PostTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.product_table.clone(), + data: product_data, + }; + let product_response = post_table_data(&context.pool, product_request, &context.indexer_tx).await.unwrap(); + + // Try to update product to reference non-existent category + let mut update_data = HashMap::new(); + update_data.insert(format!("{}_id", context.category_table), Value { kind: Some(Kind::NumberValue(99999.0)) }); + + let update_request = PutTableDataRequest { + profile_name: context.profile_name.clone(), + table_name: context.product_table.clone(), + id: product_response.inserted_id, + data: update_data, + }; + + let result = put_table_data(&context.pool, update_request, &context.indexer_tx).await; + assert!(result.is_err(), "Update with non-existent foreign key should fail"); + + if let Err(err) = result { + assert_eq!(err.code(), tonic::Code::Internal); + assert!(err.message().contains("Update failed")); + } +} +