diff --git a/common/proto/tables_data.proto b/common/proto/tables_data.proto index 53cbbe6..a13557a 100644 --- a/common/proto/tables_data.proto +++ b/common/proto/tables_data.proto @@ -6,6 +6,7 @@ import "common.proto"; service TablesData { rpc PostTableData (PostTableDataRequest) returns (PostTableDataResponse); + rpc PutTableData (PutTableDataRequest) returns (PutTableDataResponse); } message PostTableDataRequest { @@ -19,3 +20,16 @@ message PostTableDataResponse { string message = 2; int64 inserted_id = 3; } + +message PutTableDataRequest { + string profile_name = 1; + string table_name = 2; + int64 id = 3; + map data = 4; +} + +message PutTableDataResponse { + bool success = 1; + string message = 2; + int64 updated_id = 3; +} diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 47fd508..8a27db2 100644 Binary files a/common/src/proto/descriptor.bin and b/common/src/proto/descriptor.bin differ diff --git a/common/src/proto/multieko2.tables_data.rs b/common/src/proto/multieko2.tables_data.rs index f97efb1..6f54089 100644 --- a/common/src/proto/multieko2.tables_data.rs +++ b/common/src/proto/multieko2.tables_data.rs @@ -20,6 +20,29 @@ pub struct PostTableDataResponse { #[prost(int64, tag = "3")] pub inserted_id: i64, } +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PutTableDataRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub table_name: ::prost::alloc::string::String, + #[prost(int64, tag = "3")] + pub id: i64, + #[prost(map = "string, string", tag = "4")] + pub data: ::std::collections::HashMap< + ::prost::alloc::string::String, + ::prost::alloc::string::String, + >, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PutTableDataResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(string, tag = "2")] + pub message: ::prost::alloc::string::String, + #[prost(int64, tag = "3")] + pub updated_id: i64, +} /// Generated client implementations. pub mod tables_data_client { #![allow( @@ -137,6 +160,32 @@ pub mod tables_data_client { ); self.inner.unary(req, path, codec).await } + pub async fn put_table_data( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/multieko2.tables_data.TablesData/PutTableData", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("multieko2.tables_data.TablesData", "PutTableData"), + ); + self.inner.unary(req, path, codec).await + } } } /// Generated server implementations. @@ -159,6 +208,13 @@ pub mod tables_data_server { tonic::Response, tonic::Status, >; + async fn put_table_data( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } #[derive(Debug)] pub struct TablesDataServer { @@ -281,6 +337,51 @@ pub mod tables_data_server { }; Box::pin(fut) } + "/multieko2.tables_data.TablesData/PutTableData" => { + #[allow(non_camel_case_types)] + struct PutTableDataSvc(pub Arc); + impl< + T: TablesData, + > tonic::server::UnaryService + for PutTableDataSvc { + type Response = super::PutTableDataResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::put_table_data(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = PutTableDataSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } _ => { Box::pin(async move { let mut response = http::Response::new(empty_body()); diff --git a/server/src/server/services/tables_data_service.rs b/server/src/server/services/tables_data_service.rs index c0ec0ac..d119e96 100644 --- a/server/src/server/services/tables_data_service.rs +++ b/server/src/server/services/tables_data_service.rs @@ -1,8 +1,11 @@ // src/server/services/tables_data_service.rs use tonic::{Request, Response, Status}; use common::proto::multieko2::tables_data::tables_data_server::TablesData; -use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse}; -use crate::tables_data::handlers::post_table_data; +use common::proto::multieko2::tables_data::{ + PostTableDataRequest, PostTableDataResponse, + PutTableDataRequest, PutTableDataResponse // Add this import +}; +use crate::tables_data::handlers::{post_table_data, put_table_data}; // Add put_table_data use sqlx::PgPool; #[derive(Debug)] @@ -18,6 +21,16 @@ impl TablesData for TablesDataService { ) -> Result, Status> { let request = request.into_inner(); let response = post_table_data(&self.db_pool, request).await?; - Ok(Response::new(response)) // Wrap the response in a Response + Ok(Response::new(response)) + } + + // Add the new method implementation + async fn put_table_data( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let response = put_table_data(&self.db_pool, request).await?; + Ok(Response::new(response)) } } diff --git a/server/src/tables_data/docs/post_data b/server/src/tables_data/docs/post_data.txt similarity index 100% rename from server/src/tables_data/docs/post_data rename to server/src/tables_data/docs/post_data.txt diff --git a/server/src/tables_data/docs/put_data.txt b/server/src/tables_data/docs/put_data.txt new file mode 100644 index 0000000..583c671 --- /dev/null +++ b/server/src/tables_data/docs/put_data.txt @@ -0,0 +1,36 @@ +❯ grpcurl -plaintext -d '{ + "profile_name": "default", + "table_name": "2025_company_data1", + "id": 1, + "data": { + "firma": "ACME Corporation Updated", + "company_name": "ACME Corp Updated", + "textfield": "Updated sample text", + "textfield2": "Updated additional information", + "textfield3": "Updated more details", + "headquarters_psc": "54321", + "contact_phone": "987-654-3210", + "office_address": "456 Updated St, Springfield", + "support_email": "updated-support@acmecorp.com", + "is_active": "false" + } +}' localhost:50051 multieko2.tables_data.TablesData/PutTableData +{ + "success": true, + "message": "Data updated successfully", + "updatedId": "1" +} +❯ grpcurl -plaintext -d '{ + "profile_name": "default", + "table_name": "2025_company_data1", + "id": 2, + "data": { + "firma": "1", + "is_active": "true" + } +}' localhost:50051 multieko2.tables_data.TablesData/PutTableData +{ + "success": true, + "message": "Data updated successfully", + "updatedId": "2" +} diff --git a/server/src/tables_data/handlers.rs b/server/src/tables_data/handlers.rs index 282fe4c..7da9364 100644 --- a/server/src/tables_data/handlers.rs +++ b/server/src/tables_data/handlers.rs @@ -1,4 +1,6 @@ // server/src/tables_data/handlers.rs pub mod post_table_data; +pub mod put_table_data; pub use post_table_data::post_table_data; +pub use put_table_data::put_table_data; diff --git a/server/src/tables_data/handlers/put_table_data.rs b/server/src/tables_data/handlers/put_table_data.rs new file mode 100644 index 0000000..16f0c6d --- /dev/null +++ b/server/src/tables_data/handlers/put_table_data.rs @@ -0,0 +1,140 @@ +// src/tables_data/handlers/put_table_data.rs +use tonic::Status; +use sqlx::{PgPool, Arguments, Postgres}; +use sqlx::postgres::PgArguments; +use chrono::{DateTime, Utc}; +use common::proto::multieko2::tables_data::{PutTableDataRequest, PutTableDataResponse}; + +pub async fn put_table_data( + db_pool: &PgPool, + request: PutTableDataRequest, +) -> Result { + let profile_name = request.profile_name; + let table_name = request.table_name; + let record_id = request.id; + let data = request.data; + + // Lookup profile (same as POST) + let profile = sqlx::query!( + "SELECT id FROM profiles WHERE name = $1", + profile_name + ) + .fetch_optional(db_pool) + .await + .map_err(|e| Status::internal(format!("Profile lookup error: {}", e)))?; + + let profile_id = profile.ok_or_else(|| Status::not_found("Profile not found"))?.id; + + // Lookup table_definition (same as POST) + let table_def = sqlx::query!( + r#"SELECT id, columns FROM table_definitions + WHERE profile_id = $1 AND table_name = $2"#, + profile_id, + table_name + ) + .fetch_optional(db_pool) + .await + .map_err(|e| Status::internal(format!("Table lookup error: {}", e)))?; + + let table_def = table_def.ok_or_else(|| Status::not_found("Table not found"))?; + + // Parse columns from JSON (same as POST) + let columns_json: Vec = serde_json::from_value(table_def.columns.clone()) + .map_err(|e| Status::internal(format!("Column parsing error: {}", e)))?; + + let mut columns = Vec::new(); + for col_def in columns_json { + let parts: Vec<&str> = col_def.splitn(2, ' ').collect(); + if parts.len() != 2 { + return Err(Status::internal("Invalid column format")); + } + let name = parts[0].trim_matches('"').to_string(); + let sql_type = parts[1].to_string(); + columns.push((name, sql_type)); + } + + // Validate system columns + let system_columns = ["firma", "deleted"]; + let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect(); + + // Validate input columns + for key in data.keys() { + if !system_columns.contains(&key.as_str()) && !user_columns.contains(&key) { + return Err(Status::invalid_argument(format!("Invalid column: {}", key))); + } + } + + // Prepare SQL parameters + let mut params = PgArguments::default(); + let mut set_clauses = Vec::new(); + let mut param_idx = 1; + + // Add data parameters + for (col, value) in &data { + let sql_type = if system_columns.contains(&col.as_str()) { + match col.as_str() { + "firma" => "TEXT", + "deleted" => "BOOLEAN", + _ => return Err(Status::invalid_argument("Invalid system column")), + } + } else { + columns.iter() + .find(|(name, _)| name == col) + .map(|(_, sql_type)| sql_type.as_str()) + .ok_or_else(|| Status::invalid_argument(format!("Column not found: {}", col)))? + }; + + match sql_type { + "TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => { + if let Some(max_len) = sql_type.strip_prefix("VARCHAR(") + .and_then(|s| s.strip_suffix(')')) + .and_then(|s| s.parse::().ok()) + { + if value.len() > max_len { + return Err(Status::invalid_argument(format!("Value too long for {}", col))); + } + } + params.add(value); + }, + "BOOLEAN" => { + let val = value.parse::() + .map_err(|_| Status::invalid_argument(format!("Invalid boolean for {}", col)))?; + params.add(val); + }, + "TIMESTAMPTZ" => { + let dt = DateTime::parse_from_rfc3339(value) + .map_err(|_| Status::invalid_argument(format!("Invalid timestamp for {}", col)))?; + params.add(dt.with_timezone(&Utc)); + }, + _ => return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type))), + } + + set_clauses.push(format!("\"{}\" = ${}", col, param_idx)); + param_idx += 1; + } + + // Add ID parameter at the end + params.add(record_id); + + let set_clause = set_clauses.join(", "); + let sql = format!( + "UPDATE \"{}\" SET {} WHERE id = ${} AND deleted = FALSE RETURNING id", + table_name, + set_clause, + param_idx + ); + + let result = sqlx::query_scalar_with::(&sql, params) + .fetch_optional(db_pool) + .await + .map_err(|e| Status::internal(format!("Update failed: {}", e)))?; + + match result { + Some(updated_id) => Ok(PutTableDataResponse { + success: true, + message: "Data updated successfully".into(), + updated_id, + }), + None => Err(Status::not_found("Record not found or already deleted")), + } +} diff --git a/server/tests/mod.rs b/server/tests/mod.rs index 5aa5961..66b4d45 100644 --- a/server/tests/mod.rs +++ b/server/tests/mod.rs @@ -1,3 +1,5 @@ // tests/mod.rs pub mod adresar; +pub mod tables_data; pub mod common; + diff --git a/server/tests/tables_data/handlers/mod.rs b/server/tests/tables_data/handlers/mod.rs new file mode 100644 index 0000000..4cbdd4b --- /dev/null +++ b/server/tests/tables_data/handlers/mod.rs @@ -0,0 +1,3 @@ +// tests/tables_data/mod.rs +pub mod post_table_data_test; + diff --git a/server/tests/tables_data/handlers/post_table_data_test.rs b/server/tests/tables_data/handlers/post_table_data_test.rs new file mode 100644 index 0000000..bbd794a --- /dev/null +++ b/server/tests/tables_data/handlers/post_table_data_test.rs @@ -0,0 +1,241 @@ +// tests/tables_data/handlers/post_table_data_test.rs +use rstest::{fixture, rstest}; +use sqlx::PgPool; +use std::collections::HashMap; +use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse}; +use server::tables_data::handlers::post_table_data; +use crate::common::setup_test_db; +use tonic; +use chrono::Utc; + +// Fixtures +#[fixture] +async fn pool() -> PgPool { + setup_test_db().await +} + +#[fixture] +async fn closed_pool(#[future] pool: PgPool) -> PgPool { + let pool = pool.await; + pool.close().await; + pool +} + +#[fixture] +fn valid_request() -> HashMap { + let mut map = HashMap::new(); + map.insert("firma".into(), "Test Company".into()); + map.insert("kz".into(), "KZ123".into()); + map.insert("drc".into(), "DRC456".into()); + map.insert("ulica".into(), "Test Street".into()); + map.insert("psc".into(), "12345".into()); + map.insert("mesto".into(), "Test City".into()); + map.insert("stat".into(), "Test Country".into()); + map.insert("banka".into(), "Test Bank".into()); + map.insert("ucet".into(), "123456789".into()); + map.insert("skladm".into(), "Warehouse M".into()); + map.insert("ico".into(), "12345678".into()); + map.insert("kontakt".into(), "John Doe".into()); + map.insert("telefon".into(), "+421123456789".into()); + map.insert("skladu".into(), "Warehouse U".into()); + map.insert("fax".into(), "+421123456700".into()); + map +} + +#[fixture] +fn minimal_request() -> HashMap { + let mut map = HashMap::new(); + map.insert("firma".into(), "Required Only".into()); + map +} + +fn create_table_request(data: HashMap) -> PostTableDataRequest { + PostTableDataRequest { + profile_name: "default".into(), + table_name: "2025_adresar".into(), + data, + } +} + +async fn assert_table_response(pool: &PgPool, response: &PostTableDataResponse, expected: &HashMap) { + let row = sqlx::query!(r#"SELECT * FROM "2025_adresar" WHERE id = $1"#, response.inserted_id) + .fetch_one(pool) + .await + .unwrap(); + + assert_eq!(row.firma, expected["firma"]); + assert!(!row.deleted); + + // Check optional fields using direct struct access + let check_field = |field: &str, value: &str| { + let db_value = match field { + "kz" => row.kz.as_deref().unwrap_or_default(), + "drc" => row.drc.as_deref().unwrap_or_default(), + "ulica" => row.ulica.as_deref().unwrap_or_default(), + "psc" => row.psc.as_deref().unwrap_or_default(), + "mesto" => row.mesto.as_deref().unwrap_or_default(), + "stat" => row.stat.as_deref().unwrap_or_default(), + "banka" => row.banka.as_deref().unwrap_or_default(), + "ucet" => row.ucet.as_deref().unwrap_or_default(), + "skladm" => row.skladm.as_deref().unwrap_or_default(), + "ico" => row.ico.as_deref().unwrap_or_default(), + "kontakt" => row.kontakt.as_deref().unwrap_or_default(), + "telefon" => row.telefon.as_deref().unwrap_or_default(), + "skladu" => row.skladu.as_deref().unwrap_or_default(), + "fax" => row.fax.as_deref().unwrap_or_default(), + _ => panic!("Unexpected field: {}", field), + }; + assert_eq!(db_value, value); + }; + + check_field("kz", expected.get("kz").unwrap_or(&String::new())); + check_field("drc", expected.get("drc").unwrap_or(&String::new())); + check_field("ulica", expected.get("ulica").unwrap_or(&String::new())); + check_field("psc", expected.get("psc").unwrap_or(&String::new())); + check_field("mesto", expected.get("mesto").unwrap_or(&String::new())); + check_field("stat", expected.get("stat").unwrap_or(&String::new())); + check_field("banka", expected.get("banka").unwrap_or(&String::new())); + check_field("ucet", expected.get("ucet").unwrap_or(&String::new())); + check_field("skladm", expected.get("skladm").unwrap_or(&String::new())); + check_field("ico", expected.get("ico").unwrap_or(&String::new())); + check_field("kontakt", expected.get("kontakt").unwrap_or(&String::new())); + check_field("telefon", expected.get("telefon").unwrap_or(&String::new())); + check_field("skladu", expected.get("skladu").unwrap_or(&String::new())); + check_field("fax", expected.get("fax").unwrap_or(&String::new())); + + // Handle timestamp conversion + let created_at: chrono::DateTime = row.created_at.unwrap().into(); + assert!(created_at <= Utc::now()); +} + +#[rstest] +#[tokio::test] +async fn test_create_table_data_success( + #[future] pool: PgPool, + valid_request: HashMap, +) { + let pool = pool.await; + let request = create_table_request(valid_request.clone()); + let response = post_table_data(&pool, request).await.unwrap(); + + assert!(response.inserted_id > 0); + assert!(response.success); + assert_eq!(response.message, "Data inserted successfully"); + assert_table_response(&pool, &response, &valid_request).await; +} + +// Remaining tests follow the same pattern with fixed parameter declarations +#[rstest] +#[tokio::test] +async fn test_create_table_data_whitespace_trimming( + #[future] pool: PgPool, + valid_request: HashMap, +) { + let pool = pool.await; + let mut request = valid_request; + request.insert("firma".into(), " Test Company ".into()); + request.insert("telefon".into(), " +421123456789 ".into()); + + let response = post_table_data(&pool, create_table_request(request)).await.unwrap(); + + let row = sqlx::query!(r#"SELECT firma, telefon FROM "2025_adresar" WHERE id = $1"#, response.inserted_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(row.firma, "Test Company"); + assert_eq!(row.telefon.unwrap(), "+421123456789"); +} + +#[rstest] +#[tokio::test] +async fn test_create_table_data_empty_optional_fields( + #[future] pool: PgPool, + valid_request: HashMap, +) { + let pool = pool.await; + let mut request = valid_request; + request.insert("telefon".into(), " ".into()); + + let response = post_table_data(&pool, create_table_request(request)).await.unwrap(); + let telefon: Option = sqlx::query_scalar!(r#"SELECT telefon FROM "2025_adresar" WHERE id = $1"#, response.inserted_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert!(telefon.is_none()); +} + +// Fixed parameter declarations for remaining tests +#[rstest] +#[tokio::test] +async fn test_create_table_data_invalid_firma( + #[future] pool: PgPool, + valid_request: HashMap, +) { + let pool = pool.await; + let mut request = valid_request; + request.insert("firma".into(), " ".into()); + + let result = post_table_data(&pool, create_table_request(request)).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( + #[future] pool: PgPool, + minimal_request: HashMap, +) { + let pool = pool.await; + let response = post_table_data(&pool, create_table_request(minimal_request.clone())).await.unwrap(); + assert!(response.inserted_id > 0); + assert_table_response(&pool, &response, &minimal_request).await; +} + +#[rstest] +#[tokio::test] +async fn test_create_table_data_telefon_length_limit( + #[future] pool: PgPool, + valid_request: HashMap, +) { + let pool = pool.await; + let mut request = valid_request; + request.insert("telefon".into(), "1".repeat(16)); + + let result = post_table_data(&pool, create_table_request(request)).await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code(), tonic::Code::Internal); +} + +#[rstest] +#[tokio::test] +async fn test_create_table_data_special_characters( + #[future] pool: PgPool, + valid_request: HashMap, +) { + let pool = pool.await; + let mut request = valid_request; + request.insert("ulica".into(), "Náměstí 28. října 123/456".into()); + + let response = post_table_data(&pool, create_table_request(request)).await.unwrap(); + let row = sqlx::query!(r#"SELECT ulica FROM "2025_adresar" WHERE id = $1"#, response.inserted_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(row.ulica.unwrap(), "Náměstí 28. října 123/456"); +} + +#[rstest] +#[tokio::test] +async fn test_create_table_data_database_error( + #[future] closed_pool: PgPool, + minimal_request: HashMap, +) { + let closed_pool = closed_pool.await; + let result = post_table_data(&closed_pool, create_table_request(minimal_request)).await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code(), tonic::Code::Internal); +} diff --git a/server/tests/tables_data/mod.rs b/server/tests/tables_data/mod.rs new file mode 100644 index 0000000..347c06f --- /dev/null +++ b/server/tests/tables_data/mod.rs @@ -0,0 +1,2 @@ +// tests/tables_data/mod.rs +pub mod handlers;