// tests/table_definition/post_table_definition_test.rs use crate::common::setup_test_db; use common::proto::multieko2::table_definition::{ ColumnDefinition, PostTableDefinitionRequest, TableLink, }; use rstest::{fixture, rstest}; use server::table_definition::handlers::post_table_definition; use sqlx::{PgPool, Row}; use tonic::Code; // ========= 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 } /// A fixture that creates a pre-existing 'customers' table definition, /// so we can test linking to it. #[fixture] async fn pool_with_preexisting_table(#[future] pool: PgPool) -> PgPool { let pool = pool.await; let create_customers_req = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "customers".into(), columns: vec![ColumnDefinition { name: "customer_name".into(), field_type: "text".into(), }], indexes: vec!["customer_name".into()], links: vec![], }; post_table_definition(&pool, create_customers_req) .await .expect("Failed to create pre-requisite 'customers' table"); pool } // ========= Helper Functions ========= /// Checks the PostgreSQL information_schema to verify a table and its columns exist. async fn assert_table_structure_is_correct( pool: &PgPool, table_name: &str, expected_cols: &[(&str, &str)], ) { let table_exists = sqlx::query_scalar::<_, bool>( "SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'gen' AND table_name = $1 )", ) .bind(table_name) .fetch_one(pool) .await .unwrap(); assert!(table_exists, "Table 'gen.{}' was not created", table_name); for (col_name, col_type) in expected_cols { let record = sqlx::query( "SELECT data_type FROM information_schema.columns WHERE table_schema = 'gen' AND table_name = $1 AND column_name = $2", ) .bind(table_name) .bind(col_name) .fetch_optional(pool) .await .unwrap(); let found_type = record.unwrap_or_else(|| panic!("Column '{}' not found in table '{}'", col_name, table_name)).get::("data_type"); // Handle type mappings, e.g., TEXT -> character varying, NUMERIC -> numeric let normalized_found_type = found_type.to_lowercase(); let normalized_expected_type = col_type.to_lowercase(); assert!( normalized_found_type.contains(&normalized_expected_type), "Column '{}' has wrong type. Expected: {}, Found: {}", col_name, col_type, found_type ); } } // ========= Tests ========= #[rstest] #[tokio::test] async fn test_create_table_success(#[future] pool: PgPool) { // Arrange let pool = pool.await; let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "invoices".into(), columns: vec![ ColumnDefinition { name: "invoice_number".into(), field_type: "text".into(), }, ColumnDefinition { name: "amount".into(), field_type: "decimal(10, 2)".into(), }, ], indexes: vec!["invoice_number".into()], links: vec![], }; // Act let response = post_table_definition(&pool, request).await.unwrap(); // Assert assert!(response.success); assert!(response.sql.contains("CREATE TABLE gen.\"invoices\"")); assert!(response.sql.contains("\"invoice_number\" TEXT")); assert!(response.sql.contains("\"amount\" NUMERIC(10, 2)")); assert!(response .sql .contains("CREATE INDEX \"idx_invoices_invoice_number\"")); // Verify actual DB state assert_table_structure_is_correct( &pool, "invoices", &[ ("id", "bigint"), ("deleted", "boolean"), ("invoice_number", "text"), ("amount", "numeric"), ("created_at", "timestamp with time zone"), ], ) .await; } #[rstest] #[tokio::test] async fn test_fail_on_invalid_decimal_format(#[future] pool: PgPool) { let pool = pool.await; let invalid_types = vec![ "decimal(0,0)", // precision too small "decimal(5,10)", // scale > precision "decimal(10)", // missing scale "decimal(a,b)", // non-numeric ]; for invalid_type in invalid_types { let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: format!("table_{}", invalid_type), columns: vec![ColumnDefinition { name: "amount".into(), field_type: invalid_type.into(), }], ..Default::default() }; let result = post_table_definition(&pool, request).await; assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); } } #[rstest] #[tokio::test] async fn test_create_table_with_link( #[future] pool_with_preexisting_table: PgPool, ) { // Arrange let pool = pool_with_preexisting_table.await; let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "orders".into(), columns: vec![], indexes: vec![], links: vec![TableLink { // CORRECTED linked_table_name: "customers".into(), required: true, }], }; // Act let response = post_table_definition(&pool, request).await.unwrap(); // Assert assert!(response.success); assert!(response.sql.contains( "\"customers_id\" BIGINT NOT NULL REFERENCES gen.\"customers\"(id)" )); assert!(response .sql .contains("CREATE INDEX \"idx_orders_customers_fk\"")); // Verify actual DB state assert_table_structure_is_correct( &pool, "orders", &[("customers_id", "bigint")], ) .await; } #[rstest] #[tokio::test] async fn test_fail_on_duplicate_table_name(#[future] pool: PgPool) { // Arrange let pool = pool.await; let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "reused_name".into(), ..Default::default() }; // Create it once post_table_definition(&pool, request.clone()).await.unwrap(); // Act: Try to create it again let result = post_table_definition(&pool, request).await; // Assert let err = result.unwrap_err(); assert_eq!(err.code(), Code::AlreadyExists); assert_eq!(err.message(), "Table already exists in this profile"); } #[rstest] #[tokio::test] async fn test_fail_on_invalid_table_name(#[future] pool: PgPool) { let pool = pool.await; let mut request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "ends_with_id".into(), // Invalid name ..Default::default() }; let result = post_table_definition(&pool, request.clone()).await; assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); request.table_name = "deleted".into(); // Reserved name let result = post_table_definition(&pool, request.clone()).await; assert_eq!(result.unwrap_err().code(), Code::InvalidArgument); } #[rstest] #[tokio::test] async fn test_fail_on_invalid_column_type(#[future] pool: PgPool) { // Arrange let pool = pool.await; let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "bad_col_type".into(), columns: vec![ColumnDefinition { name: "some_col".into(), field_type: "super_string_9000".into(), // Invalid type }], ..Default::default() }; // Act let result = post_table_definition(&pool, request).await; // Assert let err = result.unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); assert!(err.message().contains("Invalid field type")); } #[rstest] #[tokio::test] async fn test_fail_on_index_for_nonexistent_column(#[future] pool: PgPool) { // Arrange let pool = pool.await; let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "bad_index".into(), columns: vec![ColumnDefinition { name: "real_column".into(), field_type: "text".into(), }], indexes: vec!["fake_column".into()], // Index on a column not in the list ..Default::default() }; // Act let result = post_table_definition(&pool, request).await; // Assert let err = result.unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); assert!(err.message().contains("Index column fake_column not found")); } #[rstest] #[tokio::test] async fn test_fail_on_link_to_nonexistent_table(#[future] pool: PgPool) { // Arrange let pool = pool.await; let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "bad_link".into(), links: vec![TableLink { // CORRECTED linked_table_name: "i_do_not_exist".into(), required: false, }], ..Default::default() }; // Act let result = post_table_definition(&pool, request).await; // Assert let err = result.unwrap_err(); assert_eq!(err.code(), Code::NotFound); assert!(err.message().contains("Linked table i_do_not_exist not found")); } #[rstest] #[tokio::test] async fn test_database_error_on_closed_pool( #[future] closed_pool: PgPool, ) { // Arrange let pool = closed_pool.await; let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "wont_be_created".into(), ..Default::default() }; // Act let result = post_table_definition(&pool, request).await; // Assert assert_eq!(result.unwrap_err().code(), Code::Internal); } // Tests that minimal, uppercase and whitespace‐padded decimal specs // are accepted and correctly mapped to NUMERIC(p, s). #[rstest] #[tokio::test] async fn test_valid_decimal_variants(#[future] pool: PgPool) { let pool = pool.await; let cases = vec![ ("decimal(1,1)", "NUMERIC(1, 1)"), ("decimal(1,0)", "NUMERIC(1, 0)"), ("DECIMAL(5,2)", "NUMERIC(5, 2)"), ("decimal( 5 , 2 )", "NUMERIC(5, 2)"), ]; for (i, (typ, expect)) in cases.into_iter().enumerate() { let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: format!("dec_valid_{}", i), columns: vec![ColumnDefinition { name: "amount".into(), field_type: typ.into(), }], ..Default::default() }; let resp = post_table_definition(&pool, request).await.unwrap(); assert!(resp.success, "{}", typ); assert!( resp.sql.contains(expect), "expected `{}` to map to {}, got `{}`", typ, expect, resp.sql ); } } // Tests that malformed decimal inputs are rejected with InvalidArgument. #[rstest] #[tokio::test] async fn test_fail_on_malformed_decimal_inputs(#[future] pool: PgPool) { let pool = pool.await; let bad = vec!["decimal", "decimal()", "decimal(5,)", "decimal(,2)", "decimal(, )"]; for (i, typ) in bad.into_iter().enumerate() { let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: format!("dec_bad_{}", i), columns: vec![ColumnDefinition { name: "amt".into(), field_type: typ.into(), }], ..Default::default() }; let err = post_table_definition(&pool, request).await.unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument, "{}", typ); } } // Tests that obviously invalid column identifiers are rejected // (start with digit/underscore, contain space or hyphen, or are empty). #[rstest] #[tokio::test] async fn test_fail_on_invalid_column_names(#[future] pool: PgPool) { let pool = pool.await; let bad_names = vec!["1col", "_col", "col name", "col-name", ""]; for name in bad_names { let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "tbl_invalid_cols".into(), columns: vec![ColumnDefinition { name: name.into(), field_type: "text".into(), }], ..Default::default() }; let err = post_table_definition(&pool, request).await.unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument, "{}", name); } } // Tests that a user‐supplied column ending in "_id" is rejected // to avoid collision with system‐generated FKs. #[rstest] #[tokio::test] async fn test_fail_on_column_name_suffix_id(#[future] pool: PgPool) { let pool = pool.await; let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "tbl_suffix_id".into(), columns: vec![ColumnDefinition { name: "user_id".into(), field_type: "text".into(), }], ..Default::default() }; let err = post_table_definition(&pool, request).await.unwrap_err(); assert_eq!(err.code(), Code::InvalidArgument); assert!( err.message().to_lowercase().contains("invalid column name"), "unexpected error message: {}", err.message() ); } include!("post_table_definition_test2.rs"); include!("post_table_definition_test3.rs"); include!("post_table_definition_test4.rs");