robust decimal solution to push tables data to the backend
This commit is contained in:
@@ -16,7 +16,7 @@ dotenvy = "0.15.7"
|
||||
prost = "0.13.5"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
sqlx = { version = "0.8.5", features = ["chrono", "postgres", "runtime-tokio", "runtime-tokio-native-tls", "time", "uuid"] }
|
||||
sqlx = { version = "0.8.5", features = ["chrono", "postgres", "runtime-tokio", "runtime-tokio-native-tls", "rust_decimal", "time", "uuid"] }
|
||||
tokio = { version = "1.44.2", features = ["full", "macros"] }
|
||||
tonic = "0.13.0"
|
||||
tonic-reflection = "0.13.0"
|
||||
@@ -33,6 +33,8 @@ validator = { version = "0.20.0", features = ["derive"] }
|
||||
uuid = { version = "1.16.0", features = ["serde", "v4"] }
|
||||
jsonwebtoken = "9.3.1"
|
||||
rust-stemmers = "1.2.0"
|
||||
rust_decimal = "1.37.2"
|
||||
rust_decimal_macros = "1.37.1"
|
||||
|
||||
[lib]
|
||||
name = "server"
|
||||
|
||||
@@ -8,6 +8,8 @@ use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataR
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use prost_types::value::Kind;
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::steel::server::execution::{self, Value};
|
||||
use crate::steel::server::functions::SteelContext;
|
||||
@@ -24,7 +26,6 @@ pub async fn post_table_data(
|
||||
let profile_name = request.profile_name;
|
||||
let table_name = request.table_name;
|
||||
|
||||
// Lookup profile
|
||||
let schema = sqlx::query!(
|
||||
"SELECT id FROM schemas WHERE name = $1",
|
||||
profile_name
|
||||
@@ -35,7 +36,6 @@ pub async fn post_table_data(
|
||||
|
||||
let schema_id = schema.ok_or_else(|| Status::not_found("Profile not found"))?.id;
|
||||
|
||||
// Lookup table_definition
|
||||
let table_def = sqlx::query!(
|
||||
r#"SELECT id, columns FROM table_definitions
|
||||
WHERE schema_id = $1 AND table_name = $2"#,
|
||||
@@ -48,7 +48,6 @@ pub async fn post_table_data(
|
||||
|
||||
let table_def = table_def.ok_or_else(|| Status::not_found("Table not found"))?;
|
||||
|
||||
// Parse columns from JSON
|
||||
let columns_json: Vec<String> = serde_json::from_value(table_def.columns.clone())
|
||||
.map_err(|e| Status::internal(format!("Column parsing error: {}", e)))?;
|
||||
|
||||
@@ -63,7 +62,6 @@ pub async fn post_table_data(
|
||||
columns.push((name, sql_type));
|
||||
}
|
||||
|
||||
// Get all foreign key columns for this table
|
||||
let fk_columns = sqlx::query!(
|
||||
r#"SELECT ltd.table_name
|
||||
FROM table_definition_links tdl
|
||||
@@ -75,17 +73,13 @@ pub async fn post_table_data(
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("Foreign key lookup error: {}", e)))?;
|
||||
|
||||
// Build system columns with foreign keys
|
||||
let mut system_columns = vec!["deleted".to_string()];
|
||||
for fk in fk_columns {
|
||||
let base_name = fk.table_name.split_once('_').map_or(fk.table_name.as_str(), |(_, rest)| rest);
|
||||
system_columns.push(format!("{}_id", base_name));
|
||||
system_columns.push(format!("{}_id", fk.table_name));
|
||||
}
|
||||
|
||||
// Convert to HashSet for faster lookups
|
||||
let system_columns_set: std::collections::HashSet<_> = system_columns.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
// Validate all data columns
|
||||
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
|
||||
for key in request.data.keys() {
|
||||
if !system_columns_set.contains(key.as_str()) &&
|
||||
@@ -94,17 +88,18 @@ pub async fn post_table_data(
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// FIX #1: SCRIPT VALIDATION LOOP
|
||||
// This loop now correctly handles JSON `null` (which becomes `None`).
|
||||
// ========================================================================
|
||||
let mut string_data_for_scripts = HashMap::new();
|
||||
for (key, proto_value) in &request.data {
|
||||
let str_val = match &proto_value.kind {
|
||||
Some(Kind::StringValue(s)) => s.clone(),
|
||||
Some(Kind::StringValue(s)) => {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
trimmed.to_string()
|
||||
},
|
||||
Some(Kind::NumberValue(n)) => n.to_string(),
|
||||
Some(Kind::BoolValue(b)) => b.to_string(),
|
||||
// This now correctly skips both protobuf `NULL` and JSON `null`.
|
||||
Some(Kind::NullValue(_)) | None => continue,
|
||||
Some(Kind::StructValue(_)) | Some(Kind::ListValue(_)) => {
|
||||
return Err(Status::invalid_argument(format!("Unsupported type for script validation in column '{}'", key)));
|
||||
@@ -113,7 +108,6 @@ pub async fn post_table_data(
|
||||
string_data_for_scripts.insert(key.clone(), str_val);
|
||||
}
|
||||
|
||||
// Validate Steel scripts
|
||||
let scripts = sqlx::query!(
|
||||
"SELECT target_column, script FROM table_scripts WHERE table_definitions_id = $1",
|
||||
table_def.id
|
||||
@@ -163,17 +157,11 @@ pub async fn post_table_data(
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare SQL parameters
|
||||
let mut params = PgArguments::default();
|
||||
let mut columns_list = Vec::new();
|
||||
let mut placeholders = Vec::new();
|
||||
let mut param_idx = 1;
|
||||
|
||||
// ========================================================================
|
||||
// FIX #2: DATABASE INSERTION LOOP
|
||||
// This loop now correctly handles JSON `null` (which becomes `None`)
|
||||
// without crashing and correctly inserts a SQL NULL.
|
||||
// ========================================================================
|
||||
for (col, proto_value) in request.data {
|
||||
let sql_type = if system_columns_set.contains(col.as_str()) {
|
||||
match col.as_str() {
|
||||
@@ -188,67 +176,96 @@ pub async fn post_table_data(
|
||||
.ok_or_else(|| Status::invalid_argument(format!("Column not found: {}", col)))?
|
||||
};
|
||||
|
||||
// Check for `None` (from JSON null) or `Some(NullValue)` first.
|
||||
let kind = match &proto_value.kind {
|
||||
None | Some(Kind::NullValue(_)) => {
|
||||
// It's a null value. Add the correct SQL NULL type and continue.
|
||||
match sql_type {
|
||||
"BOOLEAN" => params.add(None::<bool>),
|
||||
"TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => params.add(None::<String>),
|
||||
"TEXT" => params.add(None::<String>),
|
||||
"TIMESTAMPTZ" => params.add(None::<DateTime<Utc>>),
|
||||
"BIGINT" => params.add(None::<i64>),
|
||||
s if s.starts_with("NUMERIC") => params.add(None::<Decimal>),
|
||||
_ => return Err(Status::invalid_argument(format!("Unsupported type for null value: {}", sql_type))),
|
||||
}.map_err(|e| Status::internal(format!("Failed to add null parameter for {}: {}", col, e)))?;
|
||||
|
||||
columns_list.push(format!("\"{}\"", col));
|
||||
placeholders.push(format!("${}", param_idx));
|
||||
param_idx += 1;
|
||||
continue; // Skip to the next column in the loop
|
||||
continue;
|
||||
}
|
||||
// If it's not null, just pass the inner `Kind` through.
|
||||
Some(k) => k,
|
||||
};
|
||||
|
||||
// From here, we know `kind` is not a null type.
|
||||
match sql_type {
|
||||
"TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => {
|
||||
if let Kind::StringValue(value) = kind {
|
||||
if let Some(max_len) = sql_type.strip_prefix("VARCHAR(").and_then(|s| s.strip_suffix(')')).and_then(|s| s.parse::<usize>().ok()) {
|
||||
if value.len() > max_len {
|
||||
return Err(Status::internal(format!("Value too long for {}", col)));
|
||||
}
|
||||
if sql_type == "TEXT" {
|
||||
if let Kind::StringValue(value) = kind {
|
||||
let trimmed_value = value.trim();
|
||||
|
||||
if trimmed_value.is_empty() {
|
||||
params.add(None::<String>).map_err(|e| Status::internal(format!("Failed to add null parameter for {}: {}", col, e)))?;
|
||||
} else {
|
||||
if col == "telefon" && trimmed_value.len() > 15 {
|
||||
return Err(Status::internal(format!("Value too long for {}", col)));
|
||||
}
|
||||
params.add(value).map_err(|e| Status::invalid_argument(format!("Failed to add text parameter for {}: {}", col, e)))?;
|
||||
} else {
|
||||
return Err(Status::invalid_argument(format!("Expected string for column '{}'", col)));
|
||||
params.add(trimmed_value).map_err(|e| Status::invalid_argument(format!("Failed to add text parameter for {}: {}", col, e)))?;
|
||||
}
|
||||
},
|
||||
"BOOLEAN" => {
|
||||
if let Kind::BoolValue(val) = kind {
|
||||
params.add(val).map_err(|e| Status::invalid_argument(format!("Failed to add boolean parameter for {}: {}", col, e)))?;
|
||||
} else {
|
||||
return Err(Status::invalid_argument(format!("Expected boolean for column '{}'", col)));
|
||||
} else {
|
||||
return Err(Status::invalid_argument(format!("Expected string for column '{}'", col)));
|
||||
}
|
||||
} else if sql_type == "BOOLEAN" {
|
||||
if let Kind::BoolValue(val) = kind {
|
||||
params.add(val).map_err(|e| Status::invalid_argument(format!("Failed to add boolean parameter for {}: {}", col, e)))?;
|
||||
} else {
|
||||
return Err(Status::invalid_argument(format!("Expected boolean for column '{}'", col)));
|
||||
}
|
||||
} else if sql_type == "TIMESTAMPTZ" {
|
||||
if let Kind::StringValue(value) = kind {
|
||||
let dt = DateTime::parse_from_rfc3339(value).map_err(|_| Status::invalid_argument(format!("Invalid timestamp for {}", col)))?;
|
||||
params.add(dt.with_timezone(&Utc)).map_err(|e| Status::invalid_argument(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
|
||||
} else {
|
||||
return Err(Status::invalid_argument(format!("Expected ISO 8601 string for column '{}'", col)));
|
||||
}
|
||||
} else if sql_type == "BIGINT" {
|
||||
if let Kind::NumberValue(val) = kind {
|
||||
if val.fract() != 0.0 {
|
||||
return Err(Status::invalid_argument(format!("Expected integer for column '{}', but got a float", col)));
|
||||
}
|
||||
},
|
||||
"TIMESTAMPTZ" => {
|
||||
if let Kind::StringValue(value) = kind {
|
||||
let dt = DateTime::parse_from_rfc3339(value).map_err(|_| Status::invalid_argument(format!("Invalid timestamp for {}", col)))?;
|
||||
params.add(dt.with_timezone(&Utc)).map_err(|e| Status::invalid_argument(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
|
||||
} else {
|
||||
return Err(Status::invalid_argument(format!("Expected ISO 8601 string for column '{}'", col)));
|
||||
}
|
||||
},
|
||||
"BIGINT" => {
|
||||
if let Kind::NumberValue(val) = kind {
|
||||
if val.fract() != 0.0 {
|
||||
return Err(Status::invalid_argument(format!("Expected integer for column '{}', but got a float", col)));
|
||||
params.add(*val as i64).map_err(|e| Status::invalid_argument(format!("Failed to add integer parameter for {}: {}", col, e)))?;
|
||||
} else {
|
||||
return Err(Status::invalid_argument(format!("Expected number for column '{}'", col)));
|
||||
}
|
||||
} else if sql_type.starts_with("NUMERIC") {
|
||||
// MODIFIED: This block is now stricter.
|
||||
let decimal_val = match kind {
|
||||
Kind::StringValue(s) => {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
None // Treat empty string as NULL
|
||||
} else {
|
||||
// This is the only valid path: parse from a string.
|
||||
Some(Decimal::from_str(trimmed).map_err(|_| {
|
||||
Status::invalid_argument(format!(
|
||||
"Invalid decimal string format for column '{}': {}",
|
||||
col, s
|
||||
))
|
||||
})?)
|
||||
}
|
||||
params.add(*val as i64).map_err(|e| Status::invalid_argument(format!("Failed to add integer parameter for {}: {}", col, e)))?;
|
||||
} else {
|
||||
return Err(Status::invalid_argument(format!("Expected number for column '{}'", col)));
|
||||
}
|
||||
},
|
||||
_ => return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type))),
|
||||
// CATCH-ALL: Reject NumberValue, BoolValue, etc. for NUMERIC fields.
|
||||
_ => {
|
||||
return Err(Status::invalid_argument(format!(
|
||||
"Expected a string representation for decimal column '{}', but received a different type.",
|
||||
col
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
params.add(decimal_val).map_err(|e| {
|
||||
Status::invalid_argument(format!(
|
||||
"Failed to add decimal parameter for {}: {}",
|
||||
col, e
|
||||
))
|
||||
})?;
|
||||
} else {
|
||||
return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type)));
|
||||
}
|
||||
|
||||
columns_list.push(format!("\"{}\"", col));
|
||||
@@ -260,7 +277,6 @@ pub async fn post_table_data(
|
||||
return Err(Status::invalid_argument("No valid columns to insert"));
|
||||
}
|
||||
|
||||
// Qualify table name with schema
|
||||
let qualified_table = crate::shared::schema_qualifier::qualify_table_name_for_data(
|
||||
db_pool,
|
||||
&profile_name,
|
||||
@@ -283,6 +299,12 @@ pub async fn post_table_data(
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
if let Some(db_err) = e.as_database_error() {
|
||||
if db_err.code() == Some(std::borrow::Cow::Borrowed("22P02")) ||
|
||||
db_err.code() == Some(std::borrow::Cow::Borrowed("22003")) {
|
||||
return Err(Status::invalid_argument(format!(
|
||||
"Numeric field overflow or invalid format. Check precision and scale. Details: {}", db_err.message()
|
||||
)));
|
||||
}
|
||||
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
|
||||
return Err(Status::internal(format!(
|
||||
"Table '{}' is defined but does not physically exist in the database as {}",
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
use prost_types::Value;
|
||||
use prost_types::value::Kind;
|
||||
use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse};
|
||||
use common::proto::multieko2::table_definition::TableLink;
|
||||
use common::proto::multieko2::table_definition::{
|
||||
PostTableDefinitionRequest, ColumnDefinition as TableColumnDefinition
|
||||
};
|
||||
@@ -18,10 +19,11 @@ use server::indexer::IndexCommand;
|
||||
use sqlx::Row;
|
||||
use rand::distr::Alphanumeric;
|
||||
use rand::Rng;
|
||||
use rust_decimal::prelude::FromPrimitive;
|
||||
|
||||
// Helper function to generate unique identifiers for test isolation
|
||||
fn generate_unique_id() -> String {
|
||||
rand::thread_rng()
|
||||
rand::rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(8)
|
||||
.map(char::from)
|
||||
@@ -311,21 +313,6 @@ async fn test_create_table_data_empty_optional_fields(
|
||||
assert!(telefon.is_none());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_create_table_data_invalid_firma(
|
||||
#[future] test_context: TestContext,
|
||||
valid_request: HashMap<String, String>,
|
||||
) {
|
||||
let context = test_context.await;
|
||||
let mut request = valid_request;
|
||||
request.insert("firma".into(), " ".into());
|
||||
|
||||
let result = post_table_data(&context.pool, create_table_request(&context, request), &context.indexer_tx).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_create_table_data_minimal_request(
|
||||
@@ -392,21 +379,6 @@ async fn test_create_table_data_database_error(
|
||||
assert_eq!(result.unwrap_err().code(), tonic::Code::Internal);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_create_table_data_empty_firma(
|
||||
#[future] test_context: TestContext,
|
||||
minimal_request: HashMap<String, String>,
|
||||
) {
|
||||
let context = test_context.await;
|
||||
let mut request = minimal_request;
|
||||
request.insert("firma".into(), "".into());
|
||||
|
||||
let result = post_table_data(&context.pool, create_table_request(&context, request), &context.indexer_tx).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_create_table_data_optional_fields_null_vs_empty(
|
||||
@@ -504,3 +476,7 @@ async fn test_create_table_data_with_null_values(
|
||||
assert!(telefon.is_none());
|
||||
assert!(ulica.is_none());
|
||||
}
|
||||
|
||||
include!("post_table_data_test2.rs");
|
||||
include!("post_table_data_test3.rs");
|
||||
include!("post_table_data_test4.rs");
|
||||
|
||||
484
server/tests/tables_data/handlers/post_table_data_test2.rs
Normal file
484
server/tests/tables_data/handlers/post_table_data_test2.rs
Normal file
@@ -0,0 +1,484 @@
|
||||
// tests/tables_data/handlers/post_table_data_test2.rs
|
||||
|
||||
// ========= Additional helper functions for test2 =========
|
||||
|
||||
async fn create_test_indexer_channel() -> mpsc::Sender<IndexCommand> {
|
||||
let (tx, mut rx) = mpsc::channel(100);
|
||||
|
||||
// Spawn a task to consume indexer messages to prevent blocking
|
||||
tokio::spawn(async move {
|
||||
while let Some(_) = rx.recv().await {
|
||||
// Just consume the messages
|
||||
}
|
||||
});
|
||||
|
||||
tx
|
||||
}
|
||||
|
||||
// ========= Extended Data Type Validation Tests =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_boolean_system_column_validation(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
// Test setting the deleted flag with string (should fail)
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".into(), "System Test Company".to_string());
|
||||
data.insert("deleted".into(), "true".to_string()); // String instead of boolean
|
||||
|
||||
let result = post_table_data(&context.pool, create_table_request(&context, data), &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 'deleted'"));
|
||||
}
|
||||
}
|
||||
|
||||
// ========= String Processing and Edge Cases =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_unicode_special_characters_comprehensive(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().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 mut data = HashMap::new();
|
||||
data.insert("firma".into(), test_string.to_string());
|
||||
data.insert("kz".into(), format!("TEST{}", i));
|
||||
|
||||
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
|
||||
assert!(response.success, "Failed for string: '{}'", test_string);
|
||||
|
||||
// Verify the data was stored correctly
|
||||
let query = format!(
|
||||
r#"SELECT firma FROM "{}"."{}" WHERE id = $1"#,
|
||||
context.profile_name, context.table_name
|
||||
);
|
||||
|
||||
let stored_firma: Option<String> = sqlx::query_scalar::<_, Option<String>>(&query)
|
||||
.bind(response.inserted_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stored_firma.unwrap(), test_string.trim());
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_field_length_boundaries(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
// Test telefon field length validation (should reject >15 chars)
|
||||
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 mut data = HashMap::new();
|
||||
data.insert("firma".into(), "Length Test Company".to_string());
|
||||
data.insert("telefon".into(), test_string.to_string());
|
||||
|
||||
let result = post_table_data(&context.pool, create_table_request(&context, data), &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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========= NULL vs Empty String Handling =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_empty_strings_become_null(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let test_cases = vec![
|
||||
("", "empty_string"),
|
||||
(" ", "whitespace_only"),
|
||||
("\t\n", "tabs_newlines"),
|
||||
(" Normal Value ", "padded_value"),
|
||||
];
|
||||
|
||||
for (input, test_name) in test_cases {
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".into(), format!("Test {}", test_name));
|
||||
data.insert("ulica".into(), input.to_string());
|
||||
|
||||
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
|
||||
assert!(response.success, "Failed for test case: {}", test_name);
|
||||
|
||||
// Check what was actually stored
|
||||
let query = format!(
|
||||
r#"SELECT ulica FROM "{}"."{}" WHERE id = $1"#,
|
||||
context.profile_name, context.table_name
|
||||
);
|
||||
|
||||
let stored_ulica: Option<String> = sqlx::query_scalar::<_, Option<String>>(&query)
|
||||
.bind(response.inserted_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
assert!(stored_ulica.is_none(), "Empty/whitespace string should be NULL for: {}", test_name);
|
||||
} else {
|
||||
assert_eq!(stored_ulica.unwrap(), trimmed, "String should be trimmed for: {}", test_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========= Concurrent Operations Testing =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_inserts_same_table(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
|
||||
use futures::future::join_all;
|
||||
|
||||
// Create multiple concurrent insert operations
|
||||
let futures = (0..10).map(|i| {
|
||||
let context = context.clone();
|
||||
async move {
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".into(), format!("Concurrent Company {}", i));
|
||||
data.insert("kz".into(), format!("CONC{}", i));
|
||||
data.insert("mesto".into(), format!("City {}", i));
|
||||
|
||||
post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await
|
||||
}
|
||||
});
|
||||
|
||||
let results = join_all(futures).await;
|
||||
|
||||
// All inserts should succeed
|
||||
for (i, result) in results.into_iter().enumerate() {
|
||||
assert!(result.is_ok(), "Concurrent insert {} should succeed", i);
|
||||
}
|
||||
|
||||
// Verify all records were inserted
|
||||
let query = format!(
|
||||
r#"SELECT COUNT(*) FROM "{}"."{}" WHERE firma LIKE 'Concurrent Company%'"#,
|
||||
context.profile_name, context.table_name
|
||||
);
|
||||
|
||||
let count: i64 = sqlx::query_scalar::<_, i64>(&query)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 10);
|
||||
}
|
||||
|
||||
// ========= Error Scenarios =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_invalid_column_names(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".into(), "Valid Company".to_string());
|
||||
data.insert("nonexistent_column".into(), "Invalid".to_string());
|
||||
|
||||
let result = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(err) = result {
|
||||
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
||||
assert!(err.message().contains("Invalid column: nonexistent_column"));
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_empty_data_request(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
// Try to insert completely empty data
|
||||
let data = HashMap::new();
|
||||
|
||||
let result = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(err) = result {
|
||||
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
||||
assert!(err.message().contains("No valid columns to insert"));
|
||||
}
|
||||
}
|
||||
|
||||
// ========= Performance and Stress Testing =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_rapid_sequential_inserts(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Perform rapid sequential inserts
|
||||
for i in 0..50 {
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".into(), format!("Rapid Company {}", i));
|
||||
data.insert("kz".into(), format!("RAP{}", i));
|
||||
data.insert("telefon".into(), format!("+421{:09}", i));
|
||||
|
||||
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
|
||||
assert!(response.success, "Rapid insert {} should succeed", i);
|
||||
}
|
||||
|
||||
let duration = start_time.elapsed();
|
||||
println!("50 rapid inserts took: {:?}", duration);
|
||||
|
||||
// Verify all records were inserted
|
||||
let query = format!(
|
||||
r#"SELECT COUNT(*) FROM "{}"."{}" WHERE firma LIKE 'Rapid Company%'"#,
|
||||
context.profile_name, context.table_name
|
||||
);
|
||||
|
||||
let count: i64 = sqlx::query_scalar::<_, i64>(&query)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 50);
|
||||
}
|
||||
|
||||
// ========= SQL Injection Protection =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_sql_injection_protection(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let injection_attempts = vec![
|
||||
"'; DROP TABLE users; --",
|
||||
"1; DELETE FROM adresar; --",
|
||||
"admin'; UPDATE adresar SET firma='hacked' WHERE '1'='1",
|
||||
"' OR '1'='1",
|
||||
"'; INSERT INTO adresar (firma) VALUES ('injected'); --",
|
||||
"Robert'); DROP TABLE students; --", // Classic Bobby Tables
|
||||
];
|
||||
|
||||
let injection_count = injection_attempts.len();
|
||||
|
||||
for (i, injection) in injection_attempts.into_iter().enumerate() {
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".into(), injection.to_string());
|
||||
data.insert("kz".into(), format!("INJ{}", i));
|
||||
|
||||
// These should all succeed because values are properly parameterized
|
||||
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
|
||||
assert!(response.success, "SQL injection attempt should be safely handled: {}", injection);
|
||||
|
||||
// Verify the injection attempt was stored as literal text
|
||||
let query = format!(
|
||||
r#"SELECT firma FROM "{}"."{}" WHERE id = $1"#,
|
||||
context.profile_name, context.table_name
|
||||
);
|
||||
|
||||
let stored_firma: String = sqlx::query_scalar::<_, String>(&query)
|
||||
.bind(response.inserted_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stored_firma, injection);
|
||||
}
|
||||
|
||||
// Verify the table still exists and has the expected number of records
|
||||
let query = format!(
|
||||
r#"SELECT COUNT(*) FROM "{}"."{}" WHERE kz LIKE 'INJ%'"#,
|
||||
context.profile_name, context.table_name
|
||||
);
|
||||
|
||||
let count: i64 = sqlx::query_scalar::<_, i64>(&query)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, injection_count as i64);
|
||||
}
|
||||
|
||||
// ========= Large Data Testing =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_large_text_fields(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
// Test various large text sizes (except telefon which has length limits)
|
||||
let sizes = vec![1000, 5000, 10000];
|
||||
|
||||
for size in sizes {
|
||||
let large_text = "A".repeat(size);
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".into(), large_text.clone());
|
||||
data.insert("ulica".into(), format!("Street with {} chars", size));
|
||||
|
||||
let response = post_table_data(&context.pool, create_table_request(&context, data), &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::<_, String>(&query)
|
||||
.bind(response.inserted_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stored_firma.len(), size);
|
||||
assert_eq!(stored_firma, large_text);
|
||||
}
|
||||
}
|
||||
|
||||
// ========= Indexer Integration Testing =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_indexer_command_generation(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let (indexer_tx, mut indexer_rx) = mpsc::channel(100);
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".into(), "Indexer Test Company".to_string());
|
||||
data.insert("kz".into(), "IDX123".to_string());
|
||||
|
||||
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
|
||||
assert!(response.success);
|
||||
|
||||
// Check that indexer command was sent
|
||||
let indexer_command = tokio::time::timeout(
|
||||
tokio::time::Duration::from_millis(100),
|
||||
indexer_rx.recv()
|
||||
).await;
|
||||
|
||||
assert!(indexer_command.is_ok());
|
||||
let command = indexer_command.unwrap().unwrap();
|
||||
|
||||
match command {
|
||||
IndexCommand::AddOrUpdate(data) => {
|
||||
assert_eq!(data.table_name, context.table_name);
|
||||
assert_eq!(data.row_id, response.inserted_id);
|
||||
},
|
||||
IndexCommand::Delete(_) => panic!("Expected AddOrUpdate command, got Delete"),
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_indexer_failure_resilience(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
|
||||
// Create a closed channel to simulate indexer failure
|
||||
let (indexer_tx, indexer_rx) = mpsc::channel(1);
|
||||
drop(indexer_rx); // Close receiver to simulate failure
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".into(), "Resilience Test Company".to_string());
|
||||
data.insert("kz".into(), "RES123".to_string());
|
||||
|
||||
// Insert should still succeed even if indexer fails
|
||||
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
|
||||
assert!(response.success);
|
||||
|
||||
// Verify data was inserted despite indexer failure
|
||||
let query = format!(
|
||||
r#"SELECT COUNT(*) FROM "{}"."{}" WHERE kz = 'RES123'"#,
|
||||
context.profile_name, context.table_name
|
||||
);
|
||||
|
||||
let count: i64 = sqlx::query_scalar::<_, i64>(&query)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
// ========= Profile and Table Validation =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_nonexistent_profile_error(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".into(), "Test Company".to_string());
|
||||
|
||||
let invalid_request = PostTableDataRequest {
|
||||
profile_name: "nonexistent_profile".into(),
|
||||
table_name: context.table_name.clone(),
|
||||
data: convert_to_proto_values(data),
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, invalid_request, &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_nonexistent_table_error(#[future] test_context: TestContext) {
|
||||
let context = test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("firma".into(), "Test Company".to_string());
|
||||
|
||||
let invalid_request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: "nonexistent_table".into(),
|
||||
data: convert_to_proto_values(data),
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, invalid_request, &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"));
|
||||
}
|
||||
}
|
||||
845
server/tests/tables_data/handlers/post_table_data_test3.rs
Normal file
845
server/tests/tables_data/handlers/post_table_data_test3.rs
Normal file
@@ -0,0 +1,845 @@
|
||||
// tests/tables_data/handlers/post_table_data_test3.rs
|
||||
|
||||
// ========================================================================
|
||||
// ADDITIONAL HELPER FUNCTIONS FOR TEST3
|
||||
// ========================================================================
|
||||
|
||||
// Helper to create different Value types
|
||||
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)) }
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// FIXTURES AND CONTEXT SETUP FOR ADVANCED TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DataTypeTestContext {
|
||||
pool: PgPool,
|
||||
profile_name: String,
|
||||
table_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ForeignKeyTestContext {
|
||||
pool: PgPool,
|
||||
profile_name: String,
|
||||
category_table: String,
|
||||
product_table: String,
|
||||
order_table: String,
|
||||
}
|
||||
|
||||
// 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: "timestamp".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() },
|
||||
],
|
||||
indexes: vec![],
|
||||
links: vec![],
|
||||
};
|
||||
|
||||
post_table_definition(pool, table_def_request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Create foreign key test tables (category -> product -> order)
|
||||
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 }, // Optional link
|
||||
],
|
||||
};
|
||||
post_table_definition(pool, order_def).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
DataTypeTestContext { pool, profile_name, table_name }
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
async fn foreign_key_test_context() -> ForeignKeyTestContext {
|
||||
let pool = setup_test_db().await;
|
||||
let unique_id = generate_unique_id();
|
||||
let profile_name = format!("fk_profile_{}", unique_id);
|
||||
let category_table = format!("category_{}", unique_id);
|
||||
let product_table = format!("product_{}", unique_id);
|
||||
let order_table = format!("order_{}", 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");
|
||||
|
||||
ForeignKeyTestContext { pool, profile_name, category_table, product_table, order_table }
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// DATA TYPE VALIDATION TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_correct_data_types_success(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value("Test String"));
|
||||
data.insert("my_bool".into(), create_bool_value(true));
|
||||
data.insert("my_timestamp".into(), create_string_value("2024-01-15T10:30:00Z"));
|
||||
data.insert("my_bigint".into(), create_number_value(42.0));
|
||||
data.insert("my_money".into(), create_string_value("123.45")); // Use string for decimal
|
||||
data.insert("my_decimal".into(), create_string_value("999.99")); // Use string for decimal
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let response = post_table_data(&context.pool, request, &indexer_tx).await.unwrap();
|
||||
assert!(response.success);
|
||||
assert!(response.inserted_id > 0);
|
||||
|
||||
// Verify data was stored correctly
|
||||
let query = format!(
|
||||
r#"SELECT my_text, my_bool, my_timestamp, my_bigint FROM "{}"."{}" WHERE id = $1"#,
|
||||
context.profile_name, context.table_name
|
||||
);
|
||||
|
||||
let row = sqlx::query(&query)
|
||||
.bind(response.inserted_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: i64 = row.get("my_bigint");
|
||||
|
||||
assert_eq!(stored_text, "Test String");
|
||||
assert_eq!(stored_bool, true);
|
||||
assert_eq!(stored_bigint, 42);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_type_mismatch_string_for_boolean(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value("Required field"));
|
||||
data.insert("my_bool".into(), create_string_value("true")); // String instead of boolean
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &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_type_mismatch_string_for_integer(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value("Required field"));
|
||||
data.insert("my_bigint".into(), create_string_value("42")); // String instead of number
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &indexer_tx).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(err) = result {
|
||||
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
||||
assert!(err.message().contains("Expected number for column 'my_bigint'"));
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_type_mismatch_number_for_boolean(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value("Required field"));
|
||||
data.insert("my_bool".into(), create_number_value(1.0)); // Number instead of boolean
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &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_decimal_requires_string_not_number(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value("Required field"));
|
||||
data.insert("my_money".into(), create_number_value(123.45)); // Number instead of string for decimal
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &indexer_tx).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err(err) = result {
|
||||
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
||||
assert!(err.message().contains("Expected a string representation for decimal column 'my_money'"));
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_invalid_timestamp_format(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value("Required field"));
|
||||
data.insert("my_timestamp".into(), create_string_value("not-a-date"));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &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_float_for_integer_field(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value("Required field"));
|
||||
data.insert("my_bigint".into(), create_number_value(123.45)); // Float for integer field
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &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"));
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_valid_timestamp_formats(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().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 mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value(&format!("Test {}", i)));
|
||||
data.insert("my_timestamp".into(), create_string_value(timestamp));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &indexer_tx).await;
|
||||
assert!(result.is_ok(), "Failed for timestamp: {}", timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_boundary_integer_values(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let boundary_values = vec![
|
||||
0.0,
|
||||
1.0,
|
||||
-1.0,
|
||||
9223372036854775807.0, // i64::MAX
|
||||
-9223372036854775808.0, // i64::MIN
|
||||
];
|
||||
|
||||
for (i, value) in boundary_values.into_iter().enumerate() {
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value(&format!("Boundary test {}", i)));
|
||||
data.insert("my_bigint".into(), create_number_value(value));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &indexer_tx).await;
|
||||
assert!(result.is_ok(), "Failed for boundary value: {}", value);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_null_values_for_all_types(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value("Required for test"));
|
||||
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 = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let response = post_table_data(&context.pool, request, &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(response.inserted_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored_bool: Option<bool> = row.get("my_bool");
|
||||
let stored_timestamp: Option<chrono::DateTime<Utc>> = row.get("my_timestamp");
|
||||
let stored_bigint: Option<i64> = row.get("my_bigint");
|
||||
|
||||
assert!(stored_bool.is_none());
|
||||
assert!(stored_timestamp.is_none());
|
||||
assert!(stored_bigint.is_none());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// FOREIGN KEY CONSTRAINT TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_insert_with_valid_foreign_key(#[future] foreign_key_test_context: ForeignKeyTestContext) {
|
||||
let context = foreign_key_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
// First, insert a category
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".into(), create_string_value("Electronics"));
|
||||
category_data.insert("description".into(), create_string_value("Electronic devices"));
|
||||
|
||||
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, &indexer_tx).await.unwrap();
|
||||
let category_id = category_response.inserted_id;
|
||||
|
||||
// Now insert a product with the valid category_id
|
||||
let mut product_data = HashMap::new();
|
||||
product_data.insert("name".into(), create_string_value("Laptop"));
|
||||
product_data.insert("price".into(), create_string_value("999.99")); // Use string for decimal
|
||||
product_data.insert(format!("{}_id", context.category_table), create_number_value(category_id as f64));
|
||||
|
||||
let product_request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.product_table.clone(),
|
||||
data: product_data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, product_request, &indexer_tx).await;
|
||||
assert!(result.is_ok(), "Insert with valid foreign key should succeed");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_insert_with_nonexistent_foreign_key(#[future] foreign_key_test_context: ForeignKeyTestContext) {
|
||||
let context = foreign_key_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
// Try to insert product with non-existent category_id
|
||||
let mut product_data = HashMap::new();
|
||||
product_data.insert("name".into(), create_string_value("Laptop"));
|
||||
product_data.insert("price".into(), create_string_value("999.99"));
|
||||
product_data.insert(format!("{}_id", context.category_table), create_number_value(99999.0)); // Non-existent ID
|
||||
|
||||
let product_request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.product_table.clone(),
|
||||
data: product_data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, product_request, &indexer_tx).await;
|
||||
assert!(result.is_err(), "Insert with non-existent foreign key should fail");
|
||||
|
||||
if let Err(err) = result {
|
||||
assert_eq!(err.code(), tonic::Code::Internal);
|
||||
assert!(err.message().contains("Insert failed"));
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_insert_with_null_required_foreign_key(#[future] foreign_key_test_context: ForeignKeyTestContext) {
|
||||
let context = foreign_key_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
// Try to insert product without category_id (required foreign key)
|
||||
let mut product_data = HashMap::new();
|
||||
product_data.insert("name".into(), create_string_value("Laptop"));
|
||||
product_data.insert("price".into(), create_string_value("999.99"));
|
||||
// Intentionally omit category_id
|
||||
|
||||
let product_request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.product_table.clone(),
|
||||
data: product_data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, product_request, &indexer_tx).await;
|
||||
assert!(result.is_err(), "Insert without required foreign key should fail");
|
||||
|
||||
if let Err(err) = result {
|
||||
assert_eq!(err.code(), tonic::Code::Internal);
|
||||
assert!(err.message().contains("Insert failed"));
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_insert_with_null_optional_foreign_key(#[future] foreign_key_test_context: ForeignKeyTestContext) {
|
||||
let context = foreign_key_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
// First create a category and product for the required FK
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".into(), create_string_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, &indexer_tx).await.unwrap();
|
||||
|
||||
let mut product_data = HashMap::new();
|
||||
product_data.insert("name".into(), create_string_value("Laptop"));
|
||||
product_data.insert("price".into(), create_string_value("999.99"));
|
||||
product_data.insert(format!("{}_id", context.category_table), create_number_value(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, &indexer_tx).await.unwrap();
|
||||
|
||||
// Now insert order with required product_id but without optional category_id
|
||||
let mut order_data = HashMap::new();
|
||||
order_data.insert("quantity".into(), create_number_value(2.0));
|
||||
order_data.insert("notes".into(), create_string_value("Test order"));
|
||||
order_data.insert(format!("{}_id", context.product_table), create_number_value(product_response.inserted_id as f64));
|
||||
// Intentionally omit optional category_id
|
||||
|
||||
let order_request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.order_table.clone(),
|
||||
data: order_data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, order_request, &indexer_tx).await;
|
||||
assert!(result.is_ok(), "Insert with NULL optional foreign key should succeed");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_multiple_foreign_keys_scenario(#[future] foreign_key_test_context: ForeignKeyTestContext) {
|
||||
let context = foreign_key_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
// Create category
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".into(), create_string_value("Books"));
|
||||
|
||||
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, &indexer_tx).await.unwrap();
|
||||
|
||||
// Create product
|
||||
let mut product_data = HashMap::new();
|
||||
product_data.insert("name".into(), create_string_value("Programming Book"));
|
||||
product_data.insert("price".into(), create_string_value("49.99"));
|
||||
product_data.insert(format!("{}_id", context.category_table), create_number_value(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, &indexer_tx).await.unwrap();
|
||||
|
||||
// Create order with both foreign keys
|
||||
let mut order_data = HashMap::new();
|
||||
order_data.insert("quantity".into(), create_number_value(3.0));
|
||||
order_data.insert("notes".into(), create_string_value("Bulk order"));
|
||||
order_data.insert(format!("{}_id", context.product_table), create_number_value(product_response.inserted_id as f64));
|
||||
order_data.insert(format!("{}_id", context.category_table), create_number_value(category_response.inserted_id as f64));
|
||||
|
||||
let order_request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.order_table.clone(),
|
||||
data: order_data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, order_request, &indexer_tx).await;
|
||||
assert!(result.is_ok(), "Insert with multiple valid foreign keys should succeed");
|
||||
|
||||
// Verify the data was inserted correctly
|
||||
let product_id_col = format!("{}_id", context.product_table);
|
||||
let category_id_col = format!("{}_id", context.category_table);
|
||||
|
||||
let query = format!(
|
||||
r#"SELECT quantity, "{}", "{}" FROM "{}"."{}" WHERE id = $1"#,
|
||||
product_id_col, category_id_col, context.profile_name, context.order_table
|
||||
);
|
||||
|
||||
let row = sqlx::query(&query)
|
||||
.bind(result.unwrap().inserted_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let quantity: i64 = row.get("quantity");
|
||||
let stored_product_id: i64 = row.get(product_id_col.as_str());
|
||||
let stored_category_id: Option<i64> = row.get(category_id_col.as_str());
|
||||
|
||||
assert_eq!(quantity, 3);
|
||||
assert_eq!(stored_product_id, product_response.inserted_id);
|
||||
assert_eq!(stored_category_id.unwrap(), category_response.inserted_id);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ADDITIONAL EDGE CASE TESTS
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_extremely_large_decimal_numbers(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let large_decimals = vec![
|
||||
"1000000000.0000",
|
||||
"999999999999.99",
|
||||
"-999999999999.99",
|
||||
"0.0001",
|
||||
];
|
||||
|
||||
for (i, decimal_str) in large_decimals.into_iter().enumerate() {
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value(&format!("Large decimal test {}", i)));
|
||||
data.insert("my_money".into(), create_string_value(decimal_str));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &indexer_tx).await;
|
||||
assert!(result.is_ok(), "Failed for large decimal: {}", decimal_str);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_boolean_edge_cases(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let boolean_values = vec![true, false];
|
||||
|
||||
for (i, bool_val) in boolean_values.into_iter().enumerate() {
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value(&format!("Boolean test {}", i)));
|
||||
data.insert("my_bool".into(), create_bool_value(bool_val));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let response = post_table_data(&context.pool, request, &indexer_tx).await.unwrap();
|
||||
|
||||
// Verify boolean was stored correctly
|
||||
let query = format!(
|
||||
r#"SELECT my_bool FROM "{}"."{}" WHERE id = $1"#,
|
||||
context.profile_name, context.table_name
|
||||
);
|
||||
|
||||
let stored_bool: bool = sqlx::query_scalar::<_, bool>(&query)
|
||||
.bind(response.inserted_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stored_bool, bool_val);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_decimal_precision_handling(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let decimal_values = vec![
|
||||
"0.01",
|
||||
"99.99",
|
||||
"123.45",
|
||||
"999.99",
|
||||
"-123.45",
|
||||
];
|
||||
|
||||
for (i, decimal_val) in decimal_values.into_iter().enumerate() {
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value(&format!("Decimal test {}", i)));
|
||||
data.insert("my_decimal".into(), create_string_value(decimal_val));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &indexer_tx).await;
|
||||
assert!(result.is_ok(), "Failed for decimal value: {}", decimal_val);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_invalid_decimal_string_formats(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let invalid_decimals = vec![
|
||||
"not-a-number",
|
||||
"123.45.67",
|
||||
"abc123",
|
||||
"",
|
||||
" ",
|
||||
];
|
||||
|
||||
for (i, invalid_decimal) in invalid_decimals.into_iter().enumerate() {
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value(&format!("Invalid decimal test {}", i)));
|
||||
data.insert("my_decimal".into(), create_string_value(invalid_decimal));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &indexer_tx).await;
|
||||
|
||||
if invalid_decimal.trim().is_empty() {
|
||||
// Empty strings should be treated as NULL and succeed
|
||||
assert!(result.is_ok(), "Empty string should be treated as NULL for: {}", invalid_decimal);
|
||||
} else {
|
||||
// Invalid decimal strings should fail
|
||||
assert!(result.is_err(), "Should fail for invalid decimal: {}", invalid_decimal);
|
||||
if let Err(err) = result {
|
||||
assert_eq!(err.code(), tonic::Code::InvalidArgument);
|
||||
assert!(err.message().contains("Invalid decimal string format"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_mixed_null_and_valid_data(#[future] data_type_test_context: DataTypeTestContext) {
|
||||
let context = data_type_test_context.await;
|
||||
let indexer_tx = create_test_indexer_channel().await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("my_text".into(), create_string_value("Mixed data test"));
|
||||
data.insert("my_bool".into(), create_bool_value(true));
|
||||
data.insert("my_timestamp".into(), create_null_value());
|
||||
data.insert("my_bigint".into(), create_number_value(42.0));
|
||||
data.insert("my_money".into(), create_null_value());
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let response = post_table_data(&context.pool, request, &indexer_tx).await.unwrap();
|
||||
assert!(response.success);
|
||||
|
||||
// Verify mixed null and valid data was stored correctly
|
||||
let query = format!(
|
||||
r#"SELECT my_text, my_bool, my_timestamp, my_bigint, my_money FROM "{}"."{}" WHERE id = $1"#,
|
||||
context.profile_name, context.table_name
|
||||
);
|
||||
|
||||
let row = sqlx::query(&query)
|
||||
.bind(response.inserted_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_timestamp: Option<chrono::DateTime<Utc>> = row.get("my_timestamp");
|
||||
let stored_bigint: i64 = row.get("my_bigint");
|
||||
let stored_money: Option<rust_decimal::Decimal> = row.get("my_money");
|
||||
|
||||
assert_eq!(stored_text, "Mixed data test");
|
||||
assert_eq!(stored_bool, true);
|
||||
assert!(stored_timestamp.is_none());
|
||||
assert_eq!(stored_bigint, 42);
|
||||
assert!(stored_money.is_none());
|
||||
}
|
||||
264
server/tests/tables_data/handlers/post_table_data_test4.rs
Normal file
264
server/tests/tables_data/handlers/post_table_data_test4.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
// tests/tables_data/handlers/post_table_data_test4.rs
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
// Helper to create a protobuf Value from a string
|
||||
fn proto_string(s: &str) -> Value {
|
||||
Value {
|
||||
kind: Some(Kind::StringValue(s.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create a protobuf Value from a number
|
||||
fn proto_number(n: f64) -> Value {
|
||||
Value {
|
||||
kind: Some(Kind::NumberValue(n)),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create a protobuf Null Value
|
||||
fn proto_null() -> Value {
|
||||
Value {
|
||||
kind: Some(Kind::NullValue(0)),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create a table with various decimal types for testing
|
||||
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(),
|
||||
},
|
||||
// Standard money column
|
||||
TableColumnDefinition {
|
||||
name: "price".into(),
|
||||
field_type: "decimal(19, 4)".into(),
|
||||
},
|
||||
// Column for things like exchange rates or precise factors
|
||||
TableColumnDefinition {
|
||||
name: "rate".into(),
|
||||
field_type: "decimal(10, 5)".into(),
|
||||
},
|
||||
],
|
||||
indexes: vec![],
|
||||
links: vec![],
|
||||
};
|
||||
|
||||
post_table_definition(pool, table_def_request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// A new test context fixture for our financial table
|
||||
#[fixture]
|
||||
async fn decimal_test_context() -> TestContext {
|
||||
let pool = setup_test_db().await;
|
||||
let unique_id = generate_unique_id();
|
||||
let profile_name = format!("decimal_profile_{}", unique_id);
|
||||
let table_name = format!("invoices_{}", unique_id);
|
||||
|
||||
create_financial_table(&pool, &table_name, &profile_name)
|
||||
.await
|
||||
.expect("Failed to create decimal test table");
|
||||
|
||||
let (tx, _rx) = mpsc::channel(100);
|
||||
|
||||
TestContext {
|
||||
pool,
|
||||
profile_name,
|
||||
table_name,
|
||||
indexer_tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
// ========= DECIMAL/NUMERIC DATA TYPE TESTS =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_insert_valid_decimal_string(#[future] decimal_test_context: TestContext) {
|
||||
let context = decimal_test_context.await;
|
||||
let mut data = HashMap::new();
|
||||
data.insert("product_name".into(), proto_string("Laptop"));
|
||||
data.insert("price".into(), proto_string("1499.99"));
|
||||
data.insert("rate".into(), proto_string("-0.12345"));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let response = post_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(response.inserted_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let price: Decimal = row.get("price");
|
||||
let rate: Decimal = row.get("rate");
|
||||
|
||||
assert_eq!(price, dec!(1499.99));
|
||||
assert_eq!(rate, dec!(-0.12345));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_insert_decimal_from_number_fails(#[future] decimal_test_context: TestContext) {
|
||||
let context = decimal_test_context.await;
|
||||
let mut data = HashMap::new();
|
||||
data.insert("product_name".into(), proto_string("Mouse"));
|
||||
// THIS IS THE INVALID PART: using a number for a decimal field.
|
||||
data.insert("price".into(), proto_number(75.50));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
// The operation should fail.
|
||||
let result = post_table_data(&context.pool, request, &context.indexer_tx).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
// Verify the error is correct.
|
||||
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_decimal_rounding_behavior(#[future] decimal_test_context: TestContext) {
|
||||
let context = decimal_test_context.await;
|
||||
let mut data = HashMap::new();
|
||||
data.insert("product_name".into(), proto_string("Keyboard"));
|
||||
// price is NUMERIC(19, 4), so this should be rounded up by the database
|
||||
data.insert("price".into(), proto_string("99.12345"));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let response = post_table_data(&context.pool, request, &context.indexer_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(response.success);
|
||||
|
||||
let price: Decimal = sqlx::query_scalar(&format!(
|
||||
r#"SELECT price FROM "{}"."{}" WHERE id = $1"#,
|
||||
context.profile_name, context.table_name
|
||||
))
|
||||
.bind(response.inserted_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// PostgreSQL rounds away from zero (0.5 rounds up)
|
||||
assert_eq!(price, dec!(99.1235));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_insert_null_and_empty_string_for_decimal(
|
||||
#[future] decimal_test_context: TestContext,
|
||||
) {
|
||||
let context = decimal_test_context.await;
|
||||
let mut data = HashMap::new();
|
||||
data.insert("product_name".into(), proto_string("Monitor"));
|
||||
data.insert("price".into(), proto_string(" ")); // Empty string should be NULL
|
||||
data.insert("rate".into(), proto_null()); // Explicit NULL
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let response = post_table_data(&context.pool, request, &context.indexer_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(response.success);
|
||||
|
||||
let row = sqlx::query(&format!(
|
||||
r#"SELECT price, rate FROM "{}"."{}" WHERE id = $1"#,
|
||||
context.profile_name, context.table_name
|
||||
))
|
||||
.bind(response.inserted_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let price: Option<Decimal> = row.get("price");
|
||||
let rate: Option<Decimal> = row.get("rate");
|
||||
|
||||
assert!(price.is_none());
|
||||
assert!(rate.is_none());
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_invalid_decimal_string_fails(#[future] decimal_test_context: TestContext) {
|
||||
let context = decimal_test_context.await;
|
||||
let mut data = HashMap::new();
|
||||
data.insert("product_name".into(), proto_string("Bad Data"));
|
||||
data.insert("price".into(), proto_string("not-a-number"));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_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'"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_decimal_precision_overflow_fails(#[future] decimal_test_context: TestContext) {
|
||||
let context = decimal_test_context.await;
|
||||
let mut data = HashMap::new();
|
||||
data.insert("product_name".into(), proto_string("Too Expensive"));
|
||||
// rate is NUMERIC(10, 5), so it allows 5 digits before the decimal.
|
||||
// 123456.1 is 6 digits before, so it should fail at the database level.
|
||||
data.insert("rate".into(), proto_string("123456.1"));
|
||||
|
||||
let request = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let result = post_table_data(&context.pool, request, &context.indexer_tx).await;
|
||||
assert!(result.is_err());
|
||||
let status = result.unwrap_err();
|
||||
// This error comes from the database itself.
|
||||
assert_eq!(status.code(), tonic::Code::InvalidArgument);
|
||||
assert!(status.message().contains("Numeric field overflow"));
|
||||
}
|
||||
Reference in New Issue
Block a user