// tests/table_definition/post_table_definition_test.rs // Keep all your normal use statements use common::proto::multieko2::table_definition::{ ColumnDefinition, PostTableDefinitionRequest, TableLink, }; use rstest::{fixture, rstest}; use server::table_definition::handlers::post_table_definition; use sqlx::{postgres::PgPoolOptions, Connection, Executor, PgConnection, PgPool, Row}; // Add PgConnection etc. use tonic::Code; // Add these two new use statements for the isolation logic use rand::distr::Alphanumeric; use rand::Rng; use std::env; use dotenvy; use std::path::Path; async fn setup_isolated_gen_schema_db() -> PgPool { let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set"); let env_path = Path::new(&manifest_dir).join(".env_test"); dotenvy::from_path(env_path).ok(); let database_url = env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set"); let unique_schema_name = format!( "test_{}", rand::rng() .sample_iter(&Alphanumeric) .take(12) .map(char::from) .collect::() ); let mut root_conn = PgConnection::connect(&database_url).await.unwrap(); // Create the test schema root_conn .execute(format!("CREATE SCHEMA \"{}\"", unique_schema_name).as_str()) .await .unwrap(); // Create schemas A and B for cross-profile tests root_conn .execute("CREATE SCHEMA IF NOT EXISTS \"A\"") .await .unwrap(); root_conn .execute("CREATE SCHEMA IF NOT EXISTS \"B\"") .await .unwrap(); // IMPORTANT: Create the "default" schema if it doesn't exist root_conn .execute("CREATE SCHEMA IF NOT EXISTS \"default\"") .await .unwrap(); let pool = PgPoolOptions::new() .max_connections(5) .after_connect(move |conn, _meta| { let schema = unique_schema_name.clone(); Box::pin(async move { // Set search path to include test schema, default, A, B, and public conn.execute(format!("SET search_path = '{}', 'default', 'A', 'B', 'public'", schema).as_str()) .await?; Ok(()) }) }) .connect(&database_url) .await .expect("Failed to create isolated pool"); sqlx::migrate!() .run(&pool) .await .expect("Migrations failed in isolated schema"); // Insert into the schemas table - use INSERT ... ON CONFLICT to avoid duplicates sqlx::query!( "INSERT INTO schemas (name) VALUES ('default'), ('A'), ('B') ON CONFLICT (name) DO NOTHING" ) .execute(&pool) .await .expect("Failed to insert test schemas"); pool } // ========= Fixtures for THIS FILE ONLY ========= #[fixture] async fn pool() -> PgPool { // This fixture now calls the LOCAL, SPECIALIZED setup function. setup_isolated_gen_schema_db().await } #[fixture] async fn closed_pool(#[future] pool: PgPool) -> PgPool { let pool = pool.await; pool.close().await; pool } /// This fixture now works perfectly and is also isolated, /// because it depends on the `pool` fixture above. No changes needed here! #[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, schema_name: &str, // ADD: schema parameter table_name: &str, expected_cols: &[(&str, &str)], ) { let table_exists = sqlx::query_scalar::<_, bool>( "SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2 )", ) .bind(schema_name) // CHANGE: use dynamic schema instead of 'gen' .bind(table_name) .fetch_one(pool) .await .unwrap(); assert!(table_exists, "Table '{}.{}' was not created", schema_name, table_name); // CHANGE: dynamic schema in error message for (col_name, col_type) in expected_cols { let record = sqlx::query( "SELECT data_type FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 AND column_name = $3", ) .bind(schema_name) // CHANGE: use dynamic schema instead of 'gen' .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, schema_name, table_name)).get::("data_type"); // CHANGE: dynamic schema in error message // 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 \"default\".\"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 - FIXED: Added schema parameter assert_table_structure_is_correct( &pool, "default", // Schema name parameter "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 \"default\".\"customers\"(id)" )); assert!(response .sql .contains("CREATE INDEX \"idx_orders_customers_fk\"")); // Verify actual DB state - FIXED: Added schema parameter assert_table_structure_is_correct( &pool, "default", // Schema name parameter "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() }; let result = post_table_definition(&pool, request).await; assert!(result.is_err()); if let Err(err) = result { 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: "valid_table".into(), // FIXED: Use valid table name columns: vec![ColumnDefinition { name: "invalid_column_id".into(), // FIXED: Test invalid COLUMN name field_type: "text".into(), }], ..Default::default() }; let result = post_table_definition(&pool, request).await; assert!(result.is_err()); if let Err(status) = result { // UPDATED: Should mention column, not table assert!(status.message().contains("Column name") && status.message().contains("end with '_id'")); } } #[rstest] #[tokio::test] async fn test_invalid_characters_are_rejected(#[future] pool: PgPool) { // RENAMED: was test_name_sanitization let pool = pool.await; let req = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "My-Table!".into(), // Invalid characters columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into(), }], ..Default::default() }; // CHANGED: Now expects error instead of sanitization let result = post_table_definition(&pool, req).await; assert!(result.is_err()); if let Err(status) = result { assert_eq!(status.code(), tonic::Code::InvalidArgument); assert!(status.message().contains("Table name contains invalid characters")); } } #[rstest] #[tokio::test] async fn test_unicode_characters_are_rejected(#[future] pool: PgPool) { // RENAMED: was test_sanitization_of_unicode_and_special_chars let pool = pool.await; let request = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "produits_😂".into(), // Invalid unicode columns: vec![ColumnDefinition { name: "col_normal".into(), // Valid name field_type: "text".into(), }], ..Default::default() }; // CHANGED: Now expects error instead of sanitization let result = post_table_definition(&pool, request).await; assert!(result.is_err()); if let Err(status) = result { assert_eq!(status.code(), tonic::Code::InvalidArgument); assert!(status.message().contains("Table name contains invalid characters")); } } #[rstest] #[tokio::test] async fn test_sql_injection_attempts_are_rejected(#[future] pool: PgPool) { let pool = pool.await; let req = PostTableDefinitionRequest { profile_name: "default".into(), table_name: "users; DROP TABLE users;".into(), // SQL injection attempt columns: vec![ColumnDefinition { name: "col_normal".into(), // Valid name field_type: "text".into(), }], ..Default::default() }; // CHANGED: Now expects error instead of sanitization let result = post_table_definition(&pool, req).await; assert!(result.is_err()); if let Err(status) = result { assert_eq!(status.code(), tonic::Code::InvalidArgument); assert!(status.message().contains("Table name contains invalid characters")); } } include!("post_table_definition_test2.rs"); include!("post_table_definition_test3.rs"); include!("post_table_definition_test4.rs"); include!("post_table_definition_test5.rs");